diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e82a66..3777ecdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,33 @@ ## [Unreleased] + +## [2.2.0] + +### Bug Fixes +- Allow reconnect in offline state +- Fix ProGuard/R8 issues + - Updated consumer-rules.pro to prevent minification of several problematic methods + - Added rules to the internal SDK minification +- SDK awaits for authorization + - [!NOTE] + Event sending may be delayed until server confirms user authorization to use Chat service, + sending of events prior to this could lead to loss of such events. +### Dependency Change +- Bump kotlin from 2.0.0 to 2.0.10 +- Replace GSON with kotlinx.serialization +- Removed unused dependencies from the utilities module +### Features +- Enable application LargeHeap for attachment upload + - This allows upload of larger attachments on devices with sufficient RAM +- Enhance AuthorizeCustomer event +- Mobile SDK sends new headers for internal analytics +- Deprecate inContactId and emailAddress from Agent + - Values for these fields are now always empty +- Update agent model + - Added nickName field + - imageUrl field now provides the public image url + ## [2.1.1] ### Bug Fixes @@ -244,7 +271,8 @@ - failure - typing start/end -[Unreleased]: https://github.com/nice-devone/nice-cxone-mobile-sdk-android/compare/2.1.1...HEAD +[Unreleased]: https://github.com/nice-devone/nice-cxone-mobile-sdk-android/compare/2.2.0...HEAD +[2.2.0]: https://github.com/nice-devone/nice-cxone-mobile-sdk-android/compare/2.1.1...2.2.0 [2.1.1]: https://github.com/nice-devone/nice-cxone-mobile-sdk-android/compare/2.1.0...2.1.1 [2.1.0]: https://github.com/nice-devone/nice-cxone-mobile-sdk-android/compare/2.0.0...2.1.0 [2.0.0]: https://github.com/nice-devone/nice-cxone-mobile-sdk-android/compare/1.3.1...2.0.0 diff --git a/NOTICE b/NOTICE index 72268272..62597619 100644 --- a/NOTICE +++ b/NOTICE @@ -15,11 +15,6 @@ This product includes software from The Android Open Source Project, Androidx pr * Licensed under the Apache Apache License 2.0 * https://github.com/androidx/androidx/blob/androidx-main/LICENSE.txt -This product includes software from the Google Inc. Gson project - * Copyright 2008 Google Inc. - * Licensed under the Apache License, Version 2.0 - * https://github.com/google/gson/blob/main/LICENSE - This product includes software from the Square, Inc. Retrofit project * Copyright 2013 Square, Inc. * Licensed under the Apache Apache License 2.0 diff --git a/build.gradle b/build.gradle index f2aab365..d95d7422 100644 --- a/build.gradle +++ b/build.gradle @@ -33,10 +33,12 @@ plugins { alias(libs.plugins.firebase.crashlytics) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.metalava) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.serialization) apply false } group = GROUP -version = "2.1.1" // Fallback version +version = "2.2.0" // Fallback version allprojects { group = rootProject.group diff --git a/buildSrc/src/main/groovy/android-ui-conventions.gradle b/buildSrc/src/main/groovy/android-ui-conventions.gradle index 9a76f1a8..705d2f9b 100644 --- a/buildSrc/src/main/groovy/android-ui-conventions.gradle +++ b/buildSrc/src/main/groovy/android-ui-conventions.gradle @@ -21,10 +21,6 @@ android { defaultConfig { vectorDrawables.useSupportLibrary = true } - - buildFeatures { - viewBinding true - } } dependencies { @@ -36,7 +32,6 @@ dependencies { implementation libs.kotlin.coroutines //Navigation - implementation libs.androidx.navigation.fragment implementation libs.androidx.navigation.ui implementation libs.androidx.navigation.runtime implementation libs.androidx.navigation.compose diff --git a/buildSrc/src/main/groovy/ui-compose-conventions.gradle b/buildSrc/src/main/groovy/ui-compose-conventions.gradle index f8ef280f..48715235 100644 --- a/buildSrc/src/main/groovy/ui-compose-conventions.gradle +++ b/buildSrc/src/main/groovy/ui-compose-conventions.gradle @@ -13,6 +13,10 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ +plugins { + id "org.jetbrains.kotlin.plugin.compose" +} + android { buildFeatures { compose true @@ -28,7 +32,7 @@ dependencies { implementation libs.androidx.constraintlayout.compose implementation platform(libs.androidx.compose.bom) implementation libs.androidx.compose.activity - implementation libs.androidx.material + implementation libs.androidx.material3 implementation libs.androidx.material.icons.extended implementation libs.androidx.lifecycle.runtime.compose implementation libs.androidx.compose.ui diff --git a/chat-sdk-core/api.txt b/chat-sdk-core/api.txt index 9c8b7a39..a43aa61b 100644 --- a/chat-sdk-core/api.txt +++ b/chat-sdk-core/api.txt @@ -83,7 +83,7 @@ package com.nice.cxonechat { } @com.nice.cxonechat.Public public interface ChatEventHandler { - method public void trigger(com.nice.cxonechat.event.ChatEvent event, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method public void trigger(com.nice.cxonechat.event.ChatEvent event, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); } @com.nice.cxonechat.Public public static fun interface ChatEventHandler.OnEventErrorListener { @@ -412,15 +412,7 @@ package com.nice.cxonechat.enums { package com.nice.cxonechat.event { - @com.nice.cxonechat.Public public abstract sealed class ChatEvent { - } - - @Deprecated @com.nice.cxonechat.Public public final class CustomVisitorEvent extends com.nice.cxonechat.event.ChatEvent { - ctor @Deprecated public CustomVisitorEvent(Object data); - } - - @Deprecated @com.nice.cxonechat.Public public final class TriggerEvent extends com.nice.cxonechat.event.ChatEvent { - ctor @Deprecated public TriggerEvent(java.util.UUID id); + @com.nice.cxonechat.Public public abstract sealed class ChatEvent { } } @@ -646,11 +638,13 @@ package com.nice.cxonechat.message { method public abstract String? getImageUrl(); method public abstract String getLastName(); method public final String getName(); + method public String? getNickname(); property public abstract String firstName; property public abstract String id; property public abstract String? imageUrl; property public abstract String lastName; property public final String name; + property public String? nickname; } @com.nice.cxonechat.Public public enum MessageDirection { @@ -911,23 +905,23 @@ package com.nice.cxonechat.thread { @com.nice.cxonechat.Public public abstract class Agent { ctor public Agent(); - method public abstract String? getEmailAddress(); + method @Deprecated public abstract String? getEmailAddress(); method public abstract String getFirstName(); method public final String getFullName(); method public abstract int getId(); method public abstract String getImageUrl(); - method public abstract java.util.UUID? getInContactId(); + method @Deprecated public abstract java.util.UUID? getInContactId(); method public abstract String getLastName(); method public abstract String? getNickname(); method public abstract boolean isBotUser(); method public abstract boolean isSurveyUser(); method public abstract boolean isTyping(); - property public abstract String? emailAddress; + property @Deprecated public abstract String? emailAddress; property public abstract String firstName; property public final String fullName; property public abstract int id; property public abstract String imageUrl; - property public abstract java.util.UUID? inContactId; + property @Deprecated public abstract java.util.UUID? inContactId; property public abstract boolean isBotUser; property public abstract boolean isSurveyUser; property public abstract boolean isTyping; @@ -980,3 +974,4 @@ package com.nice.cxonechat.thread { } } + diff --git a/chat-sdk-core/build.gradle b/chat-sdk-core/build.gradle index fcfd0fc5..195af78b 100644 --- a/chat-sdk-core/build.gradle +++ b/chat-sdk-core/build.gradle @@ -23,6 +23,7 @@ plugins { id "android-library-style-conventions" id "publish-conventions" id "api-conventions" + id "org.jetbrains.kotlin.plugin.serialization" } metalava { hiddenPackages = ["com.nice.cxonechat.internal"] @@ -75,12 +76,14 @@ mavenPublishing { dependencies { implementation libs.androidx.ktx implementation libs.security.crypto - implementation libs.gson + implementation libs.kotlinx.serialization.json implementation libs.retrofit - implementation libs.retrofit.gson + implementation libs.retrofit.kotlinx.serialization implementation libs.okhttp implementation project(':utilities') api project(':logger') implementation project(':logger-android') testImplementation libs.kotest.assertions.core + testImplementation libs.kotlin.reflect + testImplementation libs.gson } diff --git a/chat-sdk-core/consumer-rules.pro b/chat-sdk-core/consumer-rules.pro index a65314ad..86b5a288 100644 --- a/chat-sdk-core/consumer-rules.pro +++ b/chat-sdk-core/consumer-rules.pro @@ -1,8 +1,16 @@ -## === GSON === -## Prevent R8 to replace instances of types that are never instantiated with null -## https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#troubleshooting-gson --keep,allowobfuscation,allowoptimization class *, **, **$**, **$**$**, **$**$**, **$**$**$** { - (...); - @com.google.gson.annotations.SerializedName ; - @com.google.gson.annotations.SerializedName ; +# Prevent false-positive unused method removal by the R8 in full mode +-keepclassmembers, allowoptimization, allowobfuscation class + com.nice.cxonechat.SocketFactoryConfiguration$Companion, + com.nice.cxonechat.ChatBuilder$Companion, + com.nice.cxonechat.message.OutboundMessage$Companion + { + public *; +} + +-keepclassmembers, allowoptimization, allowobfuscation interface + com.nice.cxonechat.SocketFactoryConfiguration, + com.nice.cxonechat.ChatBuilder, + com.nice.cxonechat.message.OutboundMessage + { + public static *; } diff --git a/chat-sdk-core/proguard-rules.pro b/chat-sdk-core/proguard-rules.pro index 92e2cc67..62f3445a 100644 --- a/chat-sdk-core/proguard-rules.pro +++ b/chat-sdk-core/proguard-rules.pro @@ -20,10 +20,8 @@ -keepclasseswithmembers,allowoptimization class **, **$**, **$**$**, **$**$**, **$**$**$** { @com.nice.cxonechat.Public public ; } --keep,allowobfuscation,allowoptimization class **, **$**, **$**$**, **$**$**, **$**$**$** { - (...); - @com.google.gson.annotations.SerializedName ; - @com.google.gson.annotations.SerializedName ; +-keepclasseswithmembernames class com.nice.cxonechat.ChatInstanceProvider$Listener { + public ; } ## === Intrinsics === @@ -50,3 +48,16 @@ ## === Suppression === -dontwarn java.lang.invoke.StringConcatFactory + +## === Serialization === +## Kotlinx.serialization rules are not effective if the classes are not used with R8 in fullmode +-keepclassmembers class com.nice.cxonechat.internal.model.**$**, com.nice.cxonechat.api.model.**$** { + kotlinx.serialization.KSerializer serializer(); +} +-keep class com.nice.cxonechat.internal.model.**, com.nice.cxonechat.api.model.** { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} +-keepclassmembers public class **$$serializer { + *; +} diff --git a/chat-sdk-core/src/main/AndroidManifest.xml b/chat-sdk-core/src/main/AndroidManifest.xml index 41c5e2d6..b61f4555 100644 --- a/chat-sdk-core/src/main/AndroidManifest.xml +++ b/chat-sdk-core/src/main/AndroidManifest.xml @@ -18,5 +18,7 @@ - + diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatBuilder.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatBuilder.kt index 81c7689e..3d67cf85 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatBuilder.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatBuilder.kt @@ -17,6 +17,7 @@ package com.nice.cxonechat import android.content.Context import androidx.annotation.CheckResult +import com.nice.cxonechat.core.BuildConfig import com.nice.cxonechat.internal.ChatBuilderDefault import com.nice.cxonechat.internal.ChatBuilderLogging import com.nice.cxonechat.internal.ChatBuilderThreading @@ -171,6 +172,15 @@ interface ChatBuilder { ): ChatBuilder { val sharedClient = OkHttpClient() .newBuilder() + .addInterceptor { chain -> + chain.proceed( + chain.request() + .newBuilder() + .addHeader("x-sdk-platform", "android") + .addHeader("x-sdk-version", BuildConfig.VERSION_NAME) + .build() + ) + } .socketFactory(TaggingSocketFactory) .build() val factory = SocketFactoryDefault(config, sharedClient) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandler.kt index 107346b8..63da9fde 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandler.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandler.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat -import com.google.gson.JsonIOException import com.nice.cxonechat.event.ChatEvent import com.nice.cxonechat.exceptions.CXOneException import com.nice.cxonechat.exceptions.MissingCustomerId @@ -39,7 +38,7 @@ interface ChatEventHandler { * @param listener nullable listener if the client wants to know when it was sent. * @param errorListener An optional listener for errors encountered when handling the event. */ - fun trigger(event: ChatEvent, listener: OnEventSentListener? = null, errorListener: OnEventErrorListener? = null) + fun trigger(event: ChatEvent<*>, listener: OnEventSentListener? = null, errorListener: OnEventErrorListener? = null) /** * Listener to be notified when the triggered event is considered sent. diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandlerActions.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandlerActions.kt index e821ab5f..14fd4b68 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandlerActions.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatEventHandlerActions.kt @@ -29,6 +29,7 @@ import com.nice.cxonechat.event.ProactiveActionFailureEvent import com.nice.cxonechat.event.ProactiveActionSuccessEvent import com.nice.cxonechat.event.RefreshToken import com.nice.cxonechat.event.TriggerEvent +import com.nice.cxonechat.internal.model.network.ProactiveActionInfo import java.util.Date import java.util.UUID @@ -262,7 +263,7 @@ object ChatEventHandlerActions { date: Date = Date(), listener: OnEventSentListener? = null, errorListener: OnEventErrorListener? = null, - ) = trigger(ProactiveActionSuccessEvent(data, date), listener, errorListener) + ) = trigger(ProactiveActionSuccessEvent(ProactiveActionInfo(data), date), listener, errorListener) /** * Refresh the authentication token associated with the chat. diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatInstanceProvider.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatInstanceProvider.kt index d418ffc8..2132af74 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatInstanceProvider.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatInstanceProvider.kt @@ -297,7 +297,17 @@ class ChatInstanceProvider private constructor( return@scope } - assertState({ setOf(Prepared, ConnectionLost).contains(it) }) { + /* + * Offline state is a special case where we allow doConnect to be called since + * the live chat implementation of Chat will re-check the availability of the chat, + * if the cached availability information is expired and will fetch a fresh version, + * if it is required. + * In case that the availability information will allow it the connection attempt will be made. + * + * Independent on the state of the availability information the instance provider + * will be notified via the onReady callback once the procedure is finished. + */ + assertState({ setOf(Prepared, ConnectionLost, Offline).contains(it) }) { "ChatInstanceProvider.connect called in invalid state ($chatState). " + "It is only allowed when the connection is either PREPARED, LOST_CONNECTION, or OFFLINE." } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/api/model/AttachmentUploadResponse.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/api/model/AttachmentUploadResponse.kt index d0fb8479..c245a93c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/api/model/AttachmentUploadResponse.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/api/model/AttachmentUploadResponse.kt @@ -15,9 +15,11 @@ package com.nice.cxonechat.api.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class AttachmentUploadResponse( - @SerializedName("fileUrl") + @SerialName("fileUrl") val fileUrl: String? = null, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ActionType.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ActionType.kt index 7bb66294..27c053d5 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ActionType.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ActionType.kt @@ -15,17 +15,19 @@ package com.nice.cxonechat.enums -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * The different types of WebSocket actions. */ +@Serializable internal enum class ActionType(val value: String) { /** An action for welcome message. */ - @SerializedName("WelcomeMessage") + @SerialName("WelcomeMessage") WelcomeMessage("WelcomeMessage"), /** An action for custom popup box. */ - @SerializedName("CustomPopupBox") + @SerialName("CustomPopupBox") CustomPopupBox("CustomPopupBox") } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ContactStatus.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ContactStatus.kt index 36994a58..ba74054a 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ContactStatus.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ContactStatus.kt @@ -15,26 +15,36 @@ package com.nice.cxonechat.enums +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + /** * The list of all statuses on a contact. */ +@Serializable internal enum class ContactStatus(val value: String) { /** The contact is newly opened. */ + @SerialName("new") New("new"), /** The contact is currently open. */ + @SerialName("open") Open("open"), /** The contact is pending. */ + @SerialName("pending") Pending("pending"), /** The contact has been escalated. */ + @SerialName("escalated") Escalated("escalated"), /** The contact has been resolved. */ + @SerialName("resolved") Resolved("resolved"), /** The contact is closed. */ + @SerialName("closed") Closed("closed"), /** The contact contains some unknown status string. */ diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ErrorType.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ErrorType.kt index a1c83034..79046af0 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ErrorType.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ErrorType.kt @@ -15,45 +15,48 @@ package com.nice.cxonechat.enums -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Definition of all errors reported by the server. */ +@Serializable internal enum class ErrorType(val value: String) { - @SerializedName("ConsumerReconnectionFailed") + @SerialName("ConsumerReconnectionFailed") ConsumerReconnectionFailed("ConsumerReconnectionFailed"), - @SerializedName("TokenRefreshingFailed") + @SerialName("TokenRefreshingFailed") TokenRefreshingFailed("TokenRefreshingFailed"), - @SerializedName("SendingMessageFailed") + @SerialName("SendingMessageFailed") SendingMessageFailed("SendingMessageFailed"), - @SerializedName("RecoveringLivechatFailed") + @SerialName("RecoveringLivechatFailed") RecoveringLivechatFailed("RecoveringLivechatFailed"), - @SerializedName("RecoveringThreadFailed") + @SerialName("RecoveringThreadFailed") RecoveringThreadFailed("RecoveringThreadFailed"), - @SerializedName("SendingOutboundFailed") + @SerialName("SendingOutboundFailed") SendingOutboundFailed("SendingOutboundFailed"), - @SerializedName("UpdatingThreadFailed") + @SerialName("UpdatingThreadFailed") UpdatingThreadFailed("UpdatingThreadFailed"), - @SerializedName("ArchivingThreadFailed") + @SerialName("ArchivingThreadFailed") ArchivingThreadFailed("ArchivingThreadFailed"), - @SerializedName("SendingTranscriptFailed") + @SerialName("SendingTranscriptFailed") SendingTranscriptFailed("SendingTranscriptFailed"), - @SerializedName("SendingOfflineMessageFailed") + @SerialName("SendingOfflineMessageFailed") SendingOfflineMessageFailed("SendingOfflineMessageFailed"), - @SerializedName("MetadataLoadFailed") + @SerialName("MetadataLoadFailed") MetadataLoadFailed("MetadataLoadFailed"), + @SerialName("S3EventLoadFailed") S3EventLoadFailed("S3EventLoadFailed"), } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventAction.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventAction.kt index dfd7add3..96e0ada6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventAction.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventAction.kt @@ -15,26 +15,28 @@ package com.nice.cxonechat.enums -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * The different types of actions for an event. */ +@Serializable internal enum class EventAction(val value: String) { /** The customer is registering for chat access. */ - @SerializedName("register") + @SerialName("register") Register("register"), /** The customer is interacting with something in the chat window. */ - @SerializedName("chatWindowEvent") + @SerialName("chatWindowEvent") ChatWindowEvent("chatWindowEvent"), /** The customer is making an outbound action. */ - @SerializedName("outbound") + @SerialName("outbound") Outbound("outbound"), /** The socket is sending a message to verify the connection. */ - @SerializedName("heartbeat") + @SerialName("heartbeat") Heartbeat("heartbeat"), } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventType.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventType.kt index 5bb0bd8d..2d7fcf52 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventType.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/EventType.kt @@ -15,179 +15,181 @@ package com.nice.cxonechat.enums -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * The different types of WebSocket events. */ +@Serializable internal enum class EventType(val value: String) { /** An event sent to authorize a customer. */ - @SerializedName("AuthorizeCustomer") + @SerialName("AuthorizeCustomer") AuthorizeCustomer("AuthorizeCustomer"), /** An event received when the customer has been successfully authorized. */ - @SerializedName("ConsumerAuthorized") + @SerialName("ConsumerAuthorized") CustomerAuthorized("ConsumerAuthorized"), /** An event sent to reconnect a returning customer. */ - @SerializedName("ReconnectConsumer") + @SerialName("ReconnectConsumer") ReconnectCustomer("ReconnectConsumer"), /** An event received when the customer has been successfully reconnected. */ - @SerializedName("ConsumerReconnected") + @SerialName("ConsumerReconnected") CustomerReconnected("ConsumerReconnected"), /** An event sent to refresh an access token. */ - @SerializedName("RefreshToken") + @SerialName("RefreshToken") RefreshToken("RefreshToken"), /** An event received when the token has been successfully refreshed. */ - @SerializedName("TokenRefreshed") + @SerialName("TokenRefreshed") TokenRefreshed("TokenRefreshed"), - @SerializedName("EndContact") + @SerialName("EndContact") EndContact("EndContact"), // Message /** An event to send a message in a chat thread. */ - @SerializedName("SendMessage") + @SerialName("SendMessage") SendMessage("SendMessage"), /** An event received when a message has been received in a chat. */ - @SerializedName("MessageCreated") + @SerialName("MessageCreated") MessageCreated("MessageCreated"), - @SerializedName("SendOutbound") + @SerialName("SendOutbound") SendOutbound("SendOutbound"), /** An event to send to load more messages in a chat thread. */ - @SerializedName("LoadMoreMessages") + @SerialName("LoadMoreMessages") LoadMoreMessages("LoadMoreMessages"), /** An event received when more messages have been received for the chat thread. */ - @SerializedName("MoreMessagesLoaded") + @SerialName("MoreMessagesLoaded") MoreMessagesLoaded("MoreMessagesLoaded"), /** An event to send to mark a chat message as seen by the customer. */ - @SerializedName("MessageSeenByCustomer") + @SerialName("MessageSeenByCustomer") MessageSeenByCustomer("MessageSeenByCustomer"), /** An event received when a message has been seen by an agent. */ - @SerializedName("MessageSeenByUser") + @SerialName("MessageSeenByUser") MessageSeenByAgent("MessageSeenByUser"), /** An event received when a read status of a message has been changed. */ - @SerializedName("MessageReadChanged") + @SerialName("MessageReadChanged") MessageReadChanged("MessageReadChanged"), // Thread /** An event to send to recover an existing chat thread in a single-thread channel. */ - @SerializedName("RecoverThread") + @SerialName("RecoverThread") RecoverThread("RecoverThread"), /** An event received when a chat thread has been recovered. */ - @SerializedName("ThreadRecovered") + @SerialName("ThreadRecovered") ThreadRecovered("ThreadRecovered"), /** An event to send to fetch the list of chat threads for the customer in a multi-thread channel. */ - @SerializedName("FetchThreadList") + @SerialName("FetchThreadList") FetchThreadList("FetchThreadList"), /** An event received when a list of chat threads has been fetched. */ - @SerializedName("ThreadListFetched") + @SerialName("ThreadListFetched") ThreadListFetched("ThreadListFetched"), /** An event to send to archive a chat thread in a multi-thread channel. */ - @SerializedName("ArchiveThread") + @SerialName("ArchiveThread") ArchiveThread("ArchiveThread"), /** An event received when a chat thread has been archived. */ - @SerializedName("ThreadArchived") + @SerialName("ThreadArchived") ThreadArchived("ThreadArchived"), /** An event to send to load metadata about a chat thread. This includes the most recent message in the thread. */ - @SerializedName("LoadThreadMetadata") + @SerialName("LoadThreadMetadata") LoadThreadMetadata("LoadThreadMetadata"), /** An event received when metadata for a chat thread has been loaded. */ - @SerializedName("ThreadMetadataLoaded") + @SerialName("ThreadMetadataLoaded") ThreadMetadataLoaded("ThreadMetadataLoaded"), /** An event sent to update the thread name and other info. */ - @SerializedName("UpdateThread") + @SerialName("UpdateThread") UpdateThread("UpdateThread"), /** An event received when the thread has been updated. */ - @SerializedName("ThreadUpdated") + @SerialName("ThreadUpdated") ThreadUpdated("ThreadUpdated"), /** Position in queue updated. */ - @SerializedName("SetPositionInQueue") + @SerialName("SetPositionInQueue") SetPositionInQueue("SetPositionInQueue"), // LiveChat /** Event which triggers livechat recover. **/ - @SerializedName("RecoverLivechat") + @SerialName("RecoverLivechat") RecoverLivechat("RecoverLivechat"), /** An event received when a live-chat thread data has been recovered. */ - @SerializedName("LivechatRecovered") + @SerialName("LivechatRecovered") LivechatRecovered("LivechatRecovered"), // Contact /** An event received when the assigned agent changes for a contact. */ - @SerializedName("CaseInboxAssigneeChanged") + @SerialName("CaseInboxAssigneeChanged") CaseInboxAssigneeChanged("CaseInboxAssigneeChanged"), - @SerializedName("CaseCreated") + @SerialName("CaseCreated") CaseCreated("CaseCreated"), // TODO: Remove? - @SerializedName("CaseStatusChanged") + @SerialName("CaseStatusChanged") CaseStatusChanged("CaseStatusChanged"), // Custom Fields /** An event to send to set custom field values for a contact (thread). */ - @SerializedName("SetContactCustomFields") + @SerialName("SetContactCustomFields") SetContactCustomFields("SetContactCustomFields"), /** An event to send to set custom field values for a customer. */ - @SerializedName("SetCustomerCustomFields") + @SerialName("SetCustomerCustomFields") SetCustomerCustomFields("SetCustomerCustomFields"), // Typing /** An event received when an agent or customer starts typing in a chat thread. */ - @SerializedName("SenderTypingStarted") + @SerialName("SenderTypingStarted") SenderTypingStarted("SenderTypingStarted"), /** An event received when an agent or customer stops typing in a chat thread. */ - @SerializedName("SenderTypingEnded") + @SerialName("SenderTypingEnded") SenderTypingEnded("SenderTypingEnded"), // Proactive Chat /** An event to send to execute an automation trigger manually. */ - @SerializedName("ExecuteTrigger") + @SerialName("ExecuteTrigger") ExecuteTrigger("ExecuteTrigger"), // Visitor - @SerializedName("StoreVisitorEvents") + @SerialName("StoreVisitorEvents") StoreVisitorEvents("StoreVisitorEvents"), - @SerializedName("SendPageViews") + @SerialName("SendPageViews") SendPageViews("SendPageViews"), - @SerializedName("FireProactiveAction") + @SerialName("FireProactiveAction") FireProactiveAction("FireProactiveAction"), // Meta Events /** A meta event sent when the actual event should be retrieved from s3. */ - @SerializedName("EventInS3") + @SerialName("EventInS3") EventInS3("EventInS3"), } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/MessageContentType.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/MessageContentType.kt index f7723988..4c4dc6f9 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/MessageContentType.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/MessageContentType.kt @@ -15,13 +15,15 @@ package com.nice.cxonechat.enums -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * The different types of messages that can be sent to the WebSocket. */ +@Serializable internal enum class MessageContentType(val value: String) { /** The message is only sending text. */ - @SerializedName("TEXT") + @SerialName("TEXT") Text("TEXT"), } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/VisitorEventType.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/VisitorEventType.kt index edab57b9..55844009 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/VisitorEventType.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/VisitorEventType.kt @@ -15,38 +15,48 @@ package com.nice.cxonechat.enums -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * The different types of visitor events. */ +@Serializable internal enum class VisitorEventType(val value: String) { /** Event for the visitor starting a new page visit. */ + @SerialName("VisitorVisit") VisitorVisit("VisitorVisit"), /** Event for the visitor viewing a page. */ - @SerializedName("PageView") + @SerialName("PageView") PageView("PageView"), /** Event for time visitor sent on a page. */ + @SerialName("TimeSpentOnPage") TimeSpentOnPage("TimeSpentOnPage"), /** Event that the chat window was opened by the visitor. */ + @SerialName("ChatWindowOpened") ChatWindowOpened("ChatWindowOpened"), /** Event that the visitor has followed a proactive action to start a chat. */ + @SerialName("Conversion") Conversion("Conversion"), /** Event that the proactive action was successfully displayed to the visitor. */ + @SerialName("ProactiveActionDisplayed") ProactiveActionDisplayed("ProactiveActionDisplayed"), /** Event that the proactive action was clicked by the visitor. */ + @SerialName("ProactiveActionClicked") ProactiveActionClicked("ProactiveActionClicked"), /** Event that the proactive action has successfully led to a conversion. */ + @SerialName("ProactiveActionSuccess") ProactiveActionSuccess("ProactiveActionSuccess"), /** Event that the proactive action has not led to a conversion within a certain time span. */ + @SerialName("ProactiveActionFailed") ProactiveActionFailed("ProactiveActionFailed"), /** A custom visitor event to send any additional data. */ diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AnalyticsEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AnalyticsEvent.kt index f359bb74..ad9723d5 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AnalyticsEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AnalyticsEvent.kt @@ -15,32 +15,51 @@ package com.nice.cxonechat.event -import com.google.gson.annotations.SerializedName +import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.enums.VisitorEventType +import com.nice.cxonechat.event.AnalyticsEvent.Data.ValueMapData +import com.nice.cxonechat.internal.model.network.Conversion +import com.nice.cxonechat.internal.model.network.ProactiveActionInfo +import com.nice.cxonechat.internal.model.network.TimeSpentOnPageModel import com.nice.cxonechat.storage.ValueStorage +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Date import java.util.UUID +import com.nice.cxonechat.internal.model.network.PageViewData as PageViewDataModel +@Serializable internal data class AnalyticsEvent( - @SerializedName("id") + @SerialName("id") + @Contextual val eventId: UUID, - @SerializedName("type") + @SerialName("type") val type: VisitorEventType, - @SerializedName("visitId") + @SerialName("visitId") + @Contextual val visitId: UUID, - @SerializedName("destination") + @SerialName("destination") val destinationId: Destination, - @SerializedName("createdAtWithMilliseconds") + @SerialName("createdAtWithMilliseconds") + @Contextual val createdAt: Date, - @SerializedName("data") - val data: Any + @SerialName("data") + val data: Data, ) { + @Serializable data class Destination( - @SerializedName("id") - val destinationId: UUID + @SerialName("id") + @Contextual + val destinationId: UUID, ) - constructor(type: VisitorEventType, storage: ValueStorage, date: Date = Date(), data: Any = mapOf()) : this( + constructor( + type: VisitorEventType, + storage: ValueStorage, + date: Date = Date(), + data: Data = ValueMapData(emptyMap()), + ) : this( UUID.randomUUID(), type, storage.visitId, @@ -48,4 +67,31 @@ internal data class AnalyticsEvent( date, data ) + + @Serializable + sealed interface Data { + @Serializable + @JvmInline + value class ProactiveActionData(val data: ProactiveActionInfo) : Data { + companion object { + operator fun invoke(data: ActionMetadata) = ProactiveActionData(ProactiveActionInfo(data)) + } + } + + @Serializable + @JvmInline + value class ValueMapData(val data: Map) : Data + + @Serializable + @JvmInline + value class ConversionData(val data: Conversion) : Data + + @Serializable + @JvmInline + value class PageViewData(val data: PageViewDataModel) : Data + + @Serializable + @JvmInline + value class TimeSpentOnPageData(val data: TimeSpentOnPageModel) : Data + } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AuthorizeCustomerEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AuthorizeCustomerEvent.kt index 7dc57959..3128192f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AuthorizeCustomerEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/AuthorizeCustomerEvent.kt @@ -22,7 +22,7 @@ import com.nice.cxonechat.storage.ValueStorage internal class AuthorizeCustomerEvent( private val code: String, private val verifier: String, -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatEvent.kt index dda65210..6d6a157d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatEvent.kt @@ -23,16 +23,16 @@ import com.nice.cxonechat.storage.ValueStorage * Definition of all available chat events which can be triggered by the application. */ @Public -sealed class ChatEvent { +sealed class ChatEvent { internal abstract fun getModel( connection: Connection, storage: ValueStorage, - ): Any + ): T internal class Custom( private val factory: (Connection, ValueStorage) -> Any, - ) : ChatEvent() { + ) : ChatEvent() { override fun getModel(connection: Connection, storage: ValueStorage): Any = factory(connection, storage) } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatWindowOpenEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatWindowOpenEvent.kt index bc9e9daf..5064eb4a 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatWindowOpenEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ChatWindowOpenEvent.kt @@ -25,11 +25,11 @@ import java.util.Date */ internal class ChatWindowOpenEvent( private val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = AnalyticsEvent(ChatWindowOpened, storage, date) + ) = AnalyticsEvent(ChatWindowOpened, storage, date) override fun toString() = "ChatWindowOpen()" } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ConversionEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ConversionEvent.kt index 0f89ea8a..b097f4e6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ConversionEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ConversionEvent.kt @@ -16,6 +16,7 @@ package com.nice.cxonechat.event import com.nice.cxonechat.enums.VisitorEventType.Conversion +import com.nice.cxonechat.event.AnalyticsEvent.Data.ConversionData import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage import java.util.Date @@ -31,21 +32,21 @@ internal class ConversionEvent( private val type: String, private val value: Number, private val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any { + ): AnalyticsEvent { val conversion = ConversionModel( type = type, - value = value, + value = value.toLong(), timestamp = date ) return AnalyticsEvent( Conversion, storage, date, - conversion, + ConversionData(conversion), ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/CustomVisitorEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/CustomVisitorEvent.kt index 542af51d..66b32def 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/CustomVisitorEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/CustomVisitorEvent.kt @@ -17,8 +17,13 @@ package com.nice.cxonechat.event import com.nice.cxonechat.Public import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionDisplayed +import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement import java.util.Date import com.nice.cxonechat.internal.model.network.ActionStoreVisitorEvent as StoreVisitorEventsModel @@ -29,19 +34,24 @@ import com.nice.cxonechat.internal.model.network.ActionStoreVisitorEvent as Stor * Generally, this can be any form of data or serializable object. * */ @Public -@Deprecated("Use ChatEventHandler.customVisitor()") -class CustomVisitorEvent( +internal class CustomVisitorEvent( private val data: Any, -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = StoreVisitorEventsModel( + ) = StoreVisitorEventsModel( connection = connection, visitor = storage.visitorId, destination = storage.destinationId, - ProactiveActionDisplayed to data, - createdAt = Date() + ProactiveActionDisplayed to data.toJsonElement(), + createdAt = Date(), ) + + private fun Any.toJsonElement(): JsonElement = when (this) { + this::class.java.isAnnotationPresent(Serializable::class.java) -> Default.serializer.encodeToJsonElement(this) + is String -> JsonPrimitive(this) + else -> Default.serializer.encodeToJsonElement(this) + } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/FetchThreadEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/FetchThreadEvent.kt index 273262c0..0e6142e6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/FetchThreadEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/FetchThreadEvent.kt @@ -19,7 +19,7 @@ import com.nice.cxonechat.internal.model.network.ActionFetchThread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage -internal object FetchThreadEvent : ChatEvent() { +internal object FetchThreadEvent : ChatEvent() { override fun getModel( connection: Connection, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/LocalEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/LocalEvent.kt index 0f835ce5..cdc6cf16 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/LocalEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/LocalEvent.kt @@ -19,8 +19,8 @@ import com.nice.cxonechat.exceptions.InternalError import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage -internal open class LocalEvent: ChatEvent() { - override fun getModel(connection: Connection, storage: ValueStorage): Any { +internal open class LocalEvent: ChatEvent() { + override fun getModel(connection: Connection, storage: ValueStorage): Nothing { throw InternalError("$this can not be serialized by getModel.") } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/PageViewEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/PageViewEvent.kt index e66ac15d..2bcdb5c8 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/PageViewEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/PageViewEvent.kt @@ -16,10 +16,11 @@ package com.nice.cxonechat.event import com.nice.cxonechat.enums.VisitorEventType.PageView -import com.nice.cxonechat.internal.model.network.PageViewData +import com.nice.cxonechat.event.AnalyticsEvent.Data.PageViewData import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage import java.util.Date +import com.nice.cxonechat.internal.model.network.PageViewData as PageViewDataModel /** * Event notifying the backend that user has visited a page in the host application. @@ -28,13 +29,13 @@ internal class PageViewEvent( internal val title: String, internal val uri: String, internal val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any { - val model = PageViewData( + ): AnalyticsEvent { + val model = PageViewDataModel( url = uri, title = title ) @@ -42,7 +43,7 @@ internal class PageViewEvent( PageView, storage, date, - model + PageViewData(model) ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionClickEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionClickEvent.kt index a91b3b66..0c1270da 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionClickEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionClickEvent.kt @@ -17,6 +17,7 @@ package com.nice.cxonechat.event import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionClicked +import com.nice.cxonechat.event.AnalyticsEvent.Data.ProactiveActionData import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage import java.util.Date @@ -27,15 +28,15 @@ import java.util.Date internal class ProactiveActionClickEvent( private val data: ActionMetadata, private val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = AnalyticsEvent( + ) = AnalyticsEvent( ProactiveActionClicked, storage = storage, date = date, - data = data + data = ProactiveActionData(data) ) override fun toString() = "ProactiveActionClickEvent(data=$data)" diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionDisplayEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionDisplayEvent.kt index 83414795..07d4f0a6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionDisplayEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionDisplayEvent.kt @@ -17,6 +17,7 @@ package com.nice.cxonechat.event import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionDisplayed +import com.nice.cxonechat.event.AnalyticsEvent.Data.ProactiveActionData import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage import java.util.Date @@ -27,15 +28,15 @@ import java.util.Date internal class ProactiveActionDisplayEvent( private val data: ActionMetadata, private val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = AnalyticsEvent( + ) = AnalyticsEvent( ProactiveActionDisplayed, storage = storage, date = date, - data = data, + data = ProactiveActionData(data), ) override fun toString() = "ProactiveActionDisplayEvent(data=$data)" diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionFailureEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionFailureEvent.kt index db4b2342..e47b561e 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionFailureEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionFailureEvent.kt @@ -17,6 +17,7 @@ package com.nice.cxonechat.event import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionFailed +import com.nice.cxonechat.event.AnalyticsEvent.Data.ProactiveActionData import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage import java.util.Date @@ -28,16 +29,16 @@ import java.util.Date internal class ProactiveActionFailureEvent( private val data: ActionMetadata, private val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = AnalyticsEvent( + ) = AnalyticsEvent( ProactiveActionFailed, storage = storage, date = date, - data = data, + data = ProactiveActionData(data), ) override fun toString() = "ProactiveActionFailureEvent(data=$data)" diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionSuccessEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionSuccessEvent.kt index 40d3240d..a333f3e0 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionSuccessEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ProactiveActionSuccessEvent.kt @@ -15,8 +15,9 @@ package com.nice.cxonechat.event -import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionSuccess +import com.nice.cxonechat.event.AnalyticsEvent.Data.ProactiveActionData +import com.nice.cxonechat.internal.model.network.ProactiveActionInfo import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage import java.util.Date @@ -25,18 +26,18 @@ import java.util.Date * Event notifying the backend that a proactive action has succeeded. */ internal class ProactiveActionSuccessEvent( - private val data: ActionMetadata, + private val data: ProactiveActionInfo, private val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = AnalyticsEvent( + ) = AnalyticsEvent( ProactiveActionSuccess, storage = storage, date = date, - data = data, + data = ProactiveActionData(data), ) override fun toString() = "ProactiveActionSuccessEvent(data=$data)" diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ReconnectCustomerEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ReconnectCustomerEvent.kt index 69a6a421..1242f3fc 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ReconnectCustomerEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/ReconnectCustomerEvent.kt @@ -19,7 +19,7 @@ import com.nice.cxonechat.internal.model.network.ActionReconnectCustomer import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage -internal object ReconnectCustomerEvent : ChatEvent() { +internal object ReconnectCustomerEvent : ChatEvent() { override fun getModel( connection: Connection, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverLiveChatThreadEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverLiveChatThreadEvent.kt index d2c30654..ae01426b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverLiveChatThreadEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverLiveChatThreadEvent.kt @@ -22,6 +22,6 @@ import java.util.UUID internal data class RecoverLiveChatThreadEvent( private val threadId: UUID? = null, -) : ChatEvent() { +) : ChatEvent() { override fun getModel(connection: Connection, storage: ValueStorage) = ActionRecoverLiveChat(connection, threadId) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverThreadEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverThreadEvent.kt index 2fba0f67..4b1b3541 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverThreadEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RecoverThreadEvent.kt @@ -22,7 +22,7 @@ import java.util.UUID internal data class RecoverThreadEvent( val threadId: UUID? -) : ChatEvent() { +) : ChatEvent() { override fun getModel(connection: Connection, storage: ValueStorage) = ActionRecoverThread( connection = connection, threadId = threadId diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RefreshToken.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RefreshToken.kt index c61842bc..e60ddb13 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RefreshToken.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/RefreshToken.kt @@ -25,7 +25,7 @@ import com.nice.cxonechat.storage.ValueStorage * This can be requested at any point, but is generally recommended to at or before * expiration of given token. * */ -internal object RefreshToken : ChatEvent() { +internal object RefreshToken : ChatEvent() { override fun getModel( connection: Connection, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/SetCustomerCustomFieldEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/SetCustomerCustomFieldEvent.kt index 6e1e76c1..f7486806 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/SetCustomerCustomFieldEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/SetCustomerCustomFieldEvent.kt @@ -22,7 +22,7 @@ import com.nice.cxonechat.storage.ValueStorage internal class SetCustomerCustomFieldEvent( private val fields: Map, -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TimeSpentOnPageEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TimeSpentOnPageEvent.kt index 9b0585cc..6aab4cd9 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TimeSpentOnPageEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TimeSpentOnPageEvent.kt @@ -16,6 +16,7 @@ package com.nice.cxonechat.event import com.nice.cxonechat.enums.VisitorEventType.TimeSpentOnPage +import com.nice.cxonechat.event.AnalyticsEvent.Data.TimeSpentOnPageData import com.nice.cxonechat.internal.model.network.TimeSpentOnPageModel import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage @@ -30,11 +31,11 @@ internal class TimeSpentOnPageEvent( private val uri: String, private val date: Date = Date(), private val timeSpentOnPage: Long -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any { + ): AnalyticsEvent { val model = TimeSpentOnPageModel( url = uri, title = title, @@ -44,7 +45,7 @@ internal class TimeSpentOnPageEvent( TimeSpentOnPage, storage, date, - model + TimeSpentOnPageData(model) ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TriggerEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TriggerEvent.kt index c86d9f5b..5a4ba755 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TriggerEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/TriggerEvent.kt @@ -27,15 +27,14 @@ import java.util.UUID * representative for more information. * */ @Public -@Deprecated("Use ChatEventHandler.event()") -class TriggerEvent( +internal class TriggerEvent( private val id: UUID, -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = ActionExecuteTrigger( + ) = ActionExecuteTrigger( connection = connection, destination = storage.destinationId, visitor = storage.visitorId, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/VisitEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/VisitEvent.kt index 901329af..fd9020ed 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/VisitEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/VisitEvent.kt @@ -28,12 +28,12 @@ import java.util.Date */ internal class VisitEvent( internal val date: Date = Date() -) : ChatEvent() { +) : ChatEvent() { override fun getModel( connection: Connection, storage: ValueStorage, - ): Any = AnalyticsEvent( + ) = AnalyticsEvent( VisitorVisit, storage = storage, date = date diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/exceptions/CXOneException.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/exceptions/CXOneException.kt index d87da3b0..c178ea73 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/exceptions/CXOneException.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/exceptions/CXOneException.kt @@ -134,7 +134,7 @@ class MissingCustomerId internal constructor() : CXOneException( * Troubleshooting: Report to CXone support. */ @Public -class InternalError internal constructor(message: String) : CXOneException(message) +class InternalError internal constructor(message: String, cause: Throwable? = null) : CXOneException(message, cause) /** * The SDK was unable to dispatch analytics event to server, due to some kind of connectivity issue. diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerImpl.kt index 58452921..4daf3bc6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerImpl.kt @@ -35,10 +35,10 @@ internal class ChatActionHandlerImpl( if (model.type != CustomPopupBox) return@addCallback val variables = model.variables if (listener == null) { - latestParams = ParamsWithMetadata(variables.orEmpty(), metadata) + latestParams = ParamsWithMetadata(variables, metadata) return@addCallback } - if (variables != null) listener.onShowPopup(variables, metadata) + listener.onShowPopup(variables, metadata) latestParams = null } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatAuthorization.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatAuthorization.kt index 5de0a104..dcf12e01 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatAuthorization.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatAuthorization.kt @@ -17,7 +17,9 @@ package com.nice.cxonechat.internal import com.nice.cxonechat.Authorization import com.nice.cxonechat.Cancellable -import com.nice.cxonechat.enums.ErrorType +import com.nice.cxonechat.ChatEventHandler +import com.nice.cxonechat.enums.ErrorType.ConsumerReconnectionFailed +import com.nice.cxonechat.enums.ErrorType.TokenRefreshingFailed import com.nice.cxonechat.event.AuthorizeCustomerEvent import com.nice.cxonechat.event.ReconnectCustomerEvent import com.nice.cxonechat.exceptions.RuntimeChatException.AuthorizationError @@ -33,7 +35,35 @@ internal class ChatAuthorization( private val authorization: Authorization, ) : ChatWithParameters by origin { - private val customerAuthorized = socketListener.addCallback(EventCustomerAuthorized) { model -> + private var cancellables = registerCallbacks() + private var delayEventsPendingAuthorization = true + private var delayedEventHandler: DelayUnauthorizedEventHandler = + DelayUnauthorizedEventHandler(ChatEventHandlerImpl(this), this) + + init { + if (storage.customerId == null) { + connection = connection.asCopyable().copy(customerId = UUIDProvider.next().toString()) + } + authorizeCustomer() + origin.eventHandlerProvider = ChatEventHandlerProvider { chat -> + var handler: ChatEventHandler = if (delayEventsPendingAuthorization) delayedEventHandler else ChatEventHandlerImpl(chat) + handler = ChatEventHandlerTokenGuard(handler, chat) + handler = ChatEventHandlerVisitGuard(handler, chat) + handler = ChatEventHandlerTimeOnPage(handler, chat) + handler = ChatEventHandlerThreading(handler, chat) + handler + } + } + + private fun registerCallbacks() = Cancellable( + getCustomerAuthorized(), + getTokenRefresh(), + getCustomerReconnectFailed(), + getTokenRefreshFailed() + ) + + private fun getCustomerAuthorized() = origin.socketListener.addCallback(EventCustomerAuthorized) { model -> + delayEventsPendingAuthorization = false val authorizationEnabled = origin.configuration.isAuthorizationEnabled connection = connection.asCopyable().copy( firstName = if (authorizationEnabled) { @@ -51,29 +81,26 @@ internal class ChatAuthorization( storage.authToken = model.token storage.authTokenExpDate = model.tokenExpiresAt storage.customerId = connection.customerId + delayedEventHandler.triggerDelayedEvents(!authorizationEnabled) } - private val tokenRefresh = socketListener.addCallback(EventTokenRefreshed) { model -> + private fun getTokenRefresh() = socketListener.addCallback(EventTokenRefreshed) { model -> + delayEventsPendingAuthorization = false storage.authToken = model.token storage.authTokenExpDate = model.expiresAt + delayedEventHandler.triggerDelayedEvents(false) } - private val customerReconnectFailed = socketListener.addErrorCallback(ErrorType.ConsumerReconnectionFailed) { - origin.chatStateListener?.onChatRuntimeException(AuthorizationError("Failed to reconnect authorized customer.")) - } - - private val tokenRefreshFailed = socketListener.addErrorCallback(ErrorType.TokenRefreshingFailed) { - origin.chatStateListener?.onChatRuntimeException(AuthorizationError("Failed to refresh authorization token.")) + private fun getCustomerReconnectFailed() = socketListener.addErrorCallback(ConsumerReconnectionFailed) { + chatStateListener?.onChatRuntimeException(AuthorizationError("Failed to reconnect authorized customer.")) } - init { - if (storage.customerId == null) { - connection = connection.asCopyable().copy(customerId = UUIDProvider.next().toString()) - } - authorizeCustomer() + private fun getTokenRefreshFailed() = socketListener.addErrorCallback(TokenRefreshingFailed) { + chatStateListener?.onChatRuntimeException(AuthorizationError("Failed to refresh authorization token.")) } private fun authorizeCustomer() { + delayEventsPendingAuthorization = true val event = when (storage.authToken == null) { true -> AuthorizeCustomerEvent(authorization.code, authorization.verifier) else -> ReconnectCustomerEvent @@ -82,14 +109,13 @@ internal class ChatAuthorization( } override fun close() { - customerAuthorized.cancel() - tokenRefresh.cancel() - customerReconnectFailed.cancel() - tokenRefreshFailed.cancel() + cancellables.cancel() origin.close() } override fun connect(): Cancellable = origin.connect().also { + cancellables.cancel() + cancellables = registerCallbacks() authorizeCustomer() } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerImpl.kt index d70b5e16..3edfb3db 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerImpl.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal -import com.google.gson.JsonParseException import com.nice.cxonechat.ChatEventHandler import com.nice.cxonechat.ChatEventHandler.OnEventErrorListener import com.nice.cxonechat.ChatEventHandler.OnEventSentListener @@ -26,6 +25,7 @@ import com.nice.cxonechat.exceptions.AnalyticsEventDispatchException import com.nice.cxonechat.exceptions.CXOneException import com.nice.cxonechat.exceptions.InternalError import com.nice.cxonechat.internal.socket.send +import kotlinx.serialization.SerializationException import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -35,7 +35,7 @@ internal class ChatEventHandlerImpl( private val chat: ChatWithParameters, ) : ChatEventHandler { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + override fun trigger(event: ChatEvent<*>, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { // Is this an internal event that doesn't get broadcast any further? if (event is LocalEvent) return @@ -44,7 +44,7 @@ internal class ChatEventHandlerImpl( }.onFailure { throwable -> when (throwable) { is CXOneException -> errorListener?.onError(throwable) - is ParseException, is JsonParseException -> errorListener?.onError(InternalError("Serialization error")) + is ParseException, is SerializationException -> errorListener?.onError(InternalError("Serialization error", throwable)) } }.getOrNull() ?: return when (model) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerLogging.kt index 9049501e..f2e532b8 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerLogging.kt @@ -32,7 +32,7 @@ internal class ChatEventHandlerLogging( ) : ChatEventHandler, LoggerScope by LoggerScope(logger) { override fun trigger( - event: ChatEvent, + event: ChatEvent<*>, listener: OnEventSentListener?, errorListener: OnEventErrorListener?, ) = scope("trigger") { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerProvider.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerProvider.kt new file mode 100644 index 00000000..3b9b9508 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.internal + +import com.nice.cxonechat.ChatEventHandler + +/** + * Interface for [ChatEventHandler] dependency injection. + */ +internal fun interface ChatEventHandlerProvider { + fun events(chat: ChatWithParameters): ChatEventHandler + + companion object { + /** + * Default implementation of [ChatEventHandlerProvider] which supplies functional [ChatEventHandler] instance. + */ + operator fun invoke() = ChatEventHandlerProvider { chat -> + var handler: ChatEventHandler + handler = ChatEventHandlerImpl(chat) + handler = ChatEventHandlerTokenGuard(handler, chat) + handler = ChatEventHandlerVisitGuard(handler, chat) + handler = ChatEventHandlerTimeOnPage(handler, chat) + handler = ChatEventHandlerThreading(handler, chat) + handler + } + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerThreading.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerThreading.kt index 3563e4bc..cf4a3c41 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerThreading.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerThreading.kt @@ -25,7 +25,7 @@ internal class ChatEventHandlerThreading( private val origin: ChatEventHandler, private val chat: ChatWithParameters, ) : ChatEventHandler { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + override fun trigger(event: ChatEvent<*>, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { chat.entrails.threading.background { try { origin.trigger(event, listener, errorListener) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTimeOnPage.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTimeOnPage.kt index 0e6faa5a..b46a2d59 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTimeOnPage.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTimeOnPage.kt @@ -28,7 +28,7 @@ internal class ChatEventHandlerTimeOnPage( private val origin: ChatEventHandler, private val chat: ChatWithParameters, ) : ChatWithParameters by chat, ChatEventHandler { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + override fun trigger(event: ChatEvent<*>, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { when (event) { is PageViewEvent -> onPageViewed(event, listener, errorListener) is PageViewEndedEvent -> onPageEnded(event, listener, errorListener) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTokenGuard.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTokenGuard.kt index dec7e730..e319169e 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTokenGuard.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerTokenGuard.kt @@ -29,7 +29,7 @@ internal class ChatEventHandlerTokenGuard( private val chat: ChatWithParameters, ) : ChatEventHandler by origin { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + override fun trigger(event: ChatEvent<*>, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { val expiresAt = chat.storage.authTokenExpDate ?: Date(Long.MAX_VALUE) if (expiresAt.expiresWithin(10.seconds) && event !is RefreshToken) { origin.trigger(RefreshToken) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuard.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuard.kt index 54392094..260ea3c5 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuard.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuard.kt @@ -29,7 +29,7 @@ internal class ChatEventHandlerVisitGuard( private val origin: ChatEventHandler, private val chat: ChatWithParameters, ) : ChatEventHandler by origin { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + override fun trigger(event: ChatEvent<*>, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { if (event is PageViewEvent) { validateVisit(event.date) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatImpl.kt index 48566b6f..f0dbf468 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatImpl.kt @@ -64,6 +64,8 @@ internal class ChatImpl( override var isChatAvailable: Boolean = true + override var eventHandlerProvider = ChatEventHandlerProvider() + override fun setDeviceToken(token: String?) { val currentToken = entrails.storage.deviceToken val newToken = token @@ -91,15 +93,7 @@ internal class ChatImpl( return handler } - override fun events(): ChatEventHandler { - var handler: ChatEventHandler - handler = ChatEventHandlerImpl(this) - handler = ChatEventHandlerTokenGuard(handler, this) - handler = ChatEventHandlerVisitGuard(handler, this) - handler = ChatEventHandlerTimeOnPage(handler, this) - handler = ChatEventHandlerThreading(handler, this) - return handler - } + override fun events(): ChatEventHandler = eventHandlerProvider.events(this) override fun customFields(): ChatFieldHandler = ChatFieldHandlerGlobal(this) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerImpl.kt index a43d7e72..67b6f4b6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerImpl.kt @@ -17,7 +17,6 @@ package com.nice.cxonechat.internal import com.nice.cxonechat.Cancellable import com.nice.cxonechat.ChatFieldHandler -import com.nice.cxonechat.ChatMode.SingleThread import com.nice.cxonechat.ChatThreadEventHandler import com.nice.cxonechat.ChatThreadHandler import com.nice.cxonechat.ChatThreadHandler.OnThreadUpdatedListener @@ -29,7 +28,6 @@ import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.updateWith import com.nice.cxonechat.internal.model.ChatThreadMutable import com.nice.cxonechat.internal.model.CustomFieldInternal.Companion.updateWith -import com.nice.cxonechat.internal.model.network.EventCaseStatusChanged import com.nice.cxonechat.internal.model.network.EventThreadRecovered import com.nice.cxonechat.internal.model.network.EventThreadUpdated import com.nice.cxonechat.internal.socket.EventCallback.Companion.addCallback @@ -56,14 +54,7 @@ internal class ChatThreadHandlerImpl( val onUpdated = chat.socketListener.addCallback(EventThreadUpdated) { listener.onUpdated(thread) } - val onArchived = if (chat.chatMode !== SingleThread) { - chat.socketListener.addCallback(EventCaseStatusChanged) { event -> - CaseStatusChangedHandlerActions.handleCaseClosed(thread, event, listener::onUpdated) - } - } else { - Cancellable.noop - } - return Cancellable(onRecovered, onUpdated, onArchived) + return Cancellable(onRecovered, onUpdated) } override fun refresh() { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMulti.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMulti.kt index a80f81c9..bf757f94 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMulti.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMulti.kt @@ -24,6 +24,7 @@ import com.nice.cxonechat.internal.model.network.EventThreadArchived import com.nice.cxonechat.internal.model.network.EventThreadUpdated import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.internal.socket.EventCallback.Companion.acceptResponse +import kotlinx.serialization.encodeToString internal class ChatThreadHandlerMulti( private val chat: ChatWithParameters, @@ -62,7 +63,7 @@ internal class ChatThreadHandlerMulti( chat.socket?.let { socket -> chat.socketListener.onMessage( socket, - Default.serializer.toJson(EventThreadUpdated(thread)) + Default.serializer.encodeToString(EventThreadUpdated(thread)) ) } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLive.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLive.kt index 8d0da107..1713a37b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLive.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLive.kt @@ -19,6 +19,7 @@ import com.nice.cxonechat.Cancellable import com.nice.cxonechat.ChatThreadHandler import com.nice.cxonechat.ChatThreadsHandler import com.nice.cxonechat.ChatThreadsHandler.OnThreadsUpdatedListener +import com.nice.cxonechat.enums.ContactStatus import com.nice.cxonechat.enums.ErrorType.RecoveringLivechatFailed import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.enums.EventType.LivechatRecovered @@ -97,7 +98,7 @@ internal class ChatThreadsHandlerLive( tmpThreadHandlerRef = null } } - val recovered = if (eventThread == null || !eventThread.canAddMoreMessages) { + val recovered = if (eventThread == null || !eventThread.canAddMoreMessages || event.lastContactStatus === ContactStatus.Closed) { createThreadIfPossible() } else { eventThread.asCopyable().copy( diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWithParameters.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWithParameters.kt index d344aed6..0dfbee94 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWithParameters.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWithParameters.kt @@ -45,4 +45,6 @@ internal interface ChatWithParameters : Chat { val storage get() = entrails.storage val service get() = entrails.service + + var eventHandlerProvider: ChatEventHandlerProvider } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/DelayUnauthorizedEventHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/DelayUnauthorizedEventHandler.kt new file mode 100644 index 00000000..389eba13 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/DelayUnauthorizedEventHandler.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.internal + +import com.nice.cxonechat.ChatEventHandler +import com.nice.cxonechat.ChatEventHandler.OnEventErrorListener +import com.nice.cxonechat.ChatEventHandler.OnEventSentListener +import com.nice.cxonechat.event.AnalyticsEvent +import com.nice.cxonechat.event.AuthorizeCustomerEvent +import com.nice.cxonechat.event.ChatEvent +import com.nice.cxonechat.event.LocalEvent +import com.nice.cxonechat.event.ReconnectCustomerEvent +import com.nice.cxonechat.event.RefreshToken +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose +import com.nice.cxonechat.util.expiresWithin +import java.util.UUID +import kotlin.time.Duration.Companion.seconds + +/** + * Class which delays events which require authorization process to complete before they can be triggered. + * It essentially serves as a buffer with a triggered flush mechanism [triggerDelayedEvents] which should be invoked once + * the authorization process is complete. + */ +internal class DelayUnauthorizedEventHandler( + private val events: ChatEventHandler, + private val chat: ChatWithParameters, + logger: Logger = chat.entrails.logger, +) : ChatEventHandler, LoggerScope by LoggerScope(logger) { + private val delayedEvents = LinkedHashMap Unit>() + private var disableDelay = false + + override fun trigger(event: ChatEvent<*>, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) = scope("trigger") { + if (eventCanSkipAuthorization(event)) { + events.trigger(event, listener, errorListener) + return@scope + } + if (delayEvent()) { + verbose( + "Delaying trigger of an event $event, pending authorization," + + " ${chat.storage.authToken} ${chat.storage.authTokenExpDate}" + ) + delayedEvents[UUID.randomUUID()] = { events.trigger(event, listener, errorListener) } + } else { + events.trigger(event, listener, errorListener) + } + } + + private fun delayEvent(): Boolean { + val authTokenExpDate = chat.storage.authTokenExpDate + return authTokenExpDate == null || chat.storage.authToken == null || authTokenExpDate.expiresWithin(1.seconds) + } + + fun triggerDelayedEvents(disableFutureDelays: Boolean) = scope("triggerDelayedEvents") { + disableDelay = disableFutureDelays + if (delayedEvents.isEmpty()) return@scope + val toTrigger = delayedEvents.toMap() + delayedEvents.keys.removeAll(toTrigger.keys) + verbose("Triggering all delayed events") + toTrigger.entries.forEach { + it.value() + } + } + + /** + * Check if the event can be triggered without waiting for authorization by the backend. + * Authorization events [AuthorizeCustomerEvent], [ReconnectCustomerEvent] and [RefreshToken] are always allowed by this filter. + * [LocalEvent] are not sent to the backend and therefore are also allowed. + * And events with model [AnalyticsEvent] are also allowed since they are sent via a different route. + * + * @return true iff the event can be triggered without waiting for authorization by the backend + */ + private fun eventCanSkipAuthorization(event: ChatEvent<*>): Boolean = when (event) { + is AuthorizeCustomerEvent -> true + is ReconnectCustomerEvent -> true + is RefreshToken -> true + is LocalEvent -> true + else -> when (event.getModel(chat.connection, chat.storage)) { + is AnalyticsEvent -> true + else -> disableDelay + } + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/RemoteServiceBuilder.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/RemoteServiceBuilder.kt index 9edb5554..9a24a372 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/RemoteServiceBuilder.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/RemoteServiceBuilder.kt @@ -17,15 +17,16 @@ package com.nice.cxonechat.internal import com.nice.cxonechat.api.RemoteService import com.nice.cxonechat.api.RemoteServiceCaching -import com.nice.cxonechat.internal.serializer.Default +import com.nice.cxonechat.internal.serializer.Default.serializer import com.nice.cxonechat.state.Connection import okhttp3.Interceptor import okhttp3.Interceptor.Chain +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Response import org.jetbrains.annotations.TestOnly import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.create import java.util.concurrent.TimeUnit @@ -56,7 +57,7 @@ internal class RemoteServiceBuilder { var service: RemoteService = Retrofit.Builder() .client(buildClient()) .baseUrl(connection.environment.chatUrl) - .addConverterFactory(GsonConverterFactory.create(Default.serializer)) + .addConverterFactory(serializer.asConverterFactory("application/json; charset=UTF-8".toMediaType())) .build() .create() service = RemoteServiceCaching(service) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/AgentCopyable.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/AgentCopyable.kt index 82de96fb..d63b50c2 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/AgentCopyable.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/AgentCopyable.kt @@ -17,7 +17,6 @@ package com.nice.cxonechat.internal.copy import com.nice.cxonechat.internal.model.AgentInternal import com.nice.cxonechat.thread.Agent -import java.util.UUID internal class AgentCopyable( private val agent: Agent, @@ -26,8 +25,6 @@ internal class AgentCopyable( @Suppress("LongParameterList") fun copy( id: Int = agent.id, - inContactId: UUID? = agent.inContactId, - emailAddress: String? = agent.emailAddress, firstName: String = agent.firstName, lastName: String = agent.lastName, nickname: String? = agent.nickname, @@ -37,8 +34,6 @@ internal class AgentCopyable( isTyping: Boolean = agent.isTyping, ) = AgentInternal( id = id, - inContactId = inContactId, - emailAddress = emailAddress, firstName = firstName, lastName = lastName, nickname = nickname, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentInternal.kt index 318b3f94..06402251 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentInternal.kt @@ -20,8 +20,10 @@ import java.util.UUID internal data class AgentInternal( override val id: Int, - override val inContactId: UUID?, - override val emailAddress: String?, + @Deprecated("inContactId is internal field and should not be used. It is now always null.") + override val inContactId: UUID? = null, + @Deprecated("emailAddress is internal field and should not be used. It is now always null.") + override val emailAddress: String? = null, override val firstName: String, override val lastName: String, override val nickname: String?, @@ -34,10 +36,6 @@ internal data class AgentInternal( override fun toString() = buildString { append("Agent(id=") append(id) - append(", inContactId=") - append(inContactId) - append(", emailAddress=") - append(emailAddress) append(", firstName='") append(firstName) append("', lastName='") diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentModel.kt index a5607d8e..09dcc290 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AgentModel.kt @@ -15,44 +15,37 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.message.MessageAuthor import com.nice.cxonechat.thread.Agent -import java.util.UUID +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class AgentModel( - @SerializedName("id") + @SerialName("id") val id: Int, - @SerializedName("inContactId") - val inContactId: UUID?, - - @SerializedName("emailAddress") - val emailAddress: String?, - - @SerializedName("firstName") + @SerialName("firstName") val firstName: String, - @SerializedName("surname") + @SerialName("surname") val surname: String, - @SerializedName("nickname") - val nickname: String?, + @SerialName("nickname") + val nickname: String? = null, - @SerializedName("isBotUser") + @SerialName("isBotUser") val isBotUser: Boolean, - @SerializedName("isSurveyUser") + @SerialName("isSurveyUser") val isSurveyUser: Boolean, - @SerializedName("imageUrl") + @SerialName("publicImageUrl") val imageUrl: String, ) { fun toAgent(): Agent = AgentInternal( id = id, - inContactId = inContactId, - emailAddress = emailAddress, firstName = firstName, lastName = surname, nickname = nickname, @@ -67,5 +60,6 @@ internal data class AgentModel( firstName = firstName, lastName = surname, imageUrl = imageUrl, + nickname = nickname, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentModel.kt index 3ee2425c..653c0685 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentModel.kt @@ -15,17 +15,19 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.message.Attachment +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class AttachmentModel( - @SerializedName("url") + @SerialName("url") val url: String, - @SerializedName("friendlyName") + @SerialName("friendlyName") val friendlyName: String, - @SerializedName("mimeType") + @SerialName("mimeType") val mimeType: String?, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentUploadModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentUploadModel.kt index 254f0e2b..f9a9bf6b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentUploadModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AttachmentUploadModel.kt @@ -17,23 +17,25 @@ package com.nice.cxonechat.internal.model import android.annotation.SuppressLint import android.util.Base64 -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.message.ContentDescriptor import com.nice.cxonechat.message.ContentDescriptor.DataSource import com.nice.cxonechat.util.applyDefaultExtension +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream @Suppress("UseDataClass") +@Serializable internal class AttachmentUploadModel { - @SerializedName("content") + @SerialName("content") val content: String - @SerializedName("mimeType") + @SerialName("mimeType") val mimeType: String - @SerializedName("fileName") + @SerialName("fileName") val fileName: String /** diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AvailabilityStatus.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AvailabilityStatus.kt index 7b55f242..d70eb775 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AvailabilityStatus.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/AvailabilityStatus.kt @@ -15,13 +15,15 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal enum class AvailabilityStatus { - @SerializedName("online") + @SerialName("online") Online, - @SerializedName("offline") + @SerialName("offline") Offline; val isOnline: Boolean diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Brand.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Brand.kt index d0056c04..e89d6152 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Brand.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Brand.kt @@ -15,15 +15,17 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable // BrandView /** * Represents all info about the brand. */ -internal data class Brand constructor( +@Serializable +internal data class Brand( /** The id of the brand. */ - @SerializedName("id") + @SerialName("id") val id: Int, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelAvailability.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelAvailability.kt index f36dbf3c..b37c33a3 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelAvailability.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelAvailability.kt @@ -15,10 +15,12 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class ChannelAvailability( - @SerializedName("status") + @SerialName("status") val status: AvailabilityStatus ) { val isOnline: Boolean diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelConfiguration.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelConfiguration.kt index 5e7bd3d8..48c84047 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelConfiguration.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelConfiguration.kt @@ -15,69 +15,75 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.AvailabilityStatus.Online import com.nice.cxonechat.state.FieldDefinitionImpl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import com.nice.cxonechat.state.FileRestrictions as PublicFileRestrictions import com.nice.cxonechat.state.FileRestrictions.AllowedFileType as PublicAllowedFileType +@Serializable internal data class ChannelConfiguration( - @SerializedName("settings") + @SerialName("settings") val settings: Settings, - @SerializedName("isAuthorizationEnabled") + @SerialName("isAuthorizationEnabled") val isAuthorizationEnabled: Boolean, - @SerializedName("preContactForm") + @SerialName("preContactForm") val preContactForm: PreContactFormModel?, - @SerializedName("caseCustomFields") + @SerialName("caseCustomFields") val contactCustomFields: List?, - @SerializedName("endUserCustomFields") + @SerialName("endUserCustomFields") val customerCustomFields: List?, - @SerializedName("isLiveChat") + @SerialName("isLiveChat") val isLiveChat: Boolean, - @SerializedName("availability") + @SerialName("availability") val availability: Availability, ) { + @Serializable data class Settings( - @SerializedName("hasMultipleThreadsPerEndUser") + @SerialName("hasMultipleThreadsPerEndUser") val hasMultipleThreadsPerEndUser: Boolean, - @SerializedName("isProactiveChatEnabled") + @SerialName("isProactiveChatEnabled") val isProactiveChatEnabled: Boolean, - @SerializedName("fileRestrictions") + @SerialName("fileRestrictions") val fileRestrictions: FileRestrictions, - @SerializedName("features") + @SerialName("features") val features: Map, ) + @Serializable data class FileRestrictions( - @SerializedName("allowedFileSize") + @SerialName("allowedFileSize") val allowedFileSize: Int, - @SerializedName("allowedFileTypes") + @SerialName("allowedFileTypes") val allowedFileTypes: List, - @SerializedName("isAttachmentsEnabled") + @SerialName("isAttachmentsEnabled") val isAttachmentsEnabled: Boolean, ) + @Serializable data class AllowedFileType( - @SerializedName("mimeType") + @SerialName("mimeType") val mimeType: String, - @SerializedName("description") + @SerialName("description") val description: String, ) + @Serializable data class Availability( - @SerializedName("status") + @SerialName("status") val status: AvailabilityStatus, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelIdentifier.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelIdentifier.kt index 1f2c7e61..947ef0bc 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelIdentifier.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChannelIdentifier.kt @@ -15,16 +15,18 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable // ChannelView /** * Uniquely identifies a channel. */ +@Serializable internal data class ChannelIdentifier( /** The id of the channel. */ - @SerializedName("id") + @SerialName("id") val id: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConfigurationInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConfigurationInternal.kt index e7548fed..4643f404 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConfigurationInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConfigurationInternal.kt @@ -25,7 +25,9 @@ internal data class ConfigurationInternal( override val isProactiveChatEnabled: Boolean, override val isAuthorizationEnabled: Boolean, internal val preContactSurvey: PreChatSurvey?, + @Deprecated("Client side validation of [FieldDefinition]s is no longer supported.") override val contactCustomFields: FieldDefinitionList, + @Deprecated("Client side validation of [FieldDefinition]s is no longer supported.") override val customerCustomFields: FieldDefinitionList, override val fileRestrictions: FileRestrictions, override val isLiveChat: Boolean, diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Contact.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Contact.kt index 493bf2cd..fc575800 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Contact.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Contact.kt @@ -15,9 +15,11 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.ContactStatus -import java.util.Date +import com.nice.cxonechat.internal.serializer.DateAsString +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID // ContactView @@ -25,19 +27,20 @@ import java.util.UUID /** * Represents all info about a contact (case). */ - -internal data class Contact constructor( +@Serializable +internal data class Contact( /** The id of the contact. */ - @SerializedName("id") + @SerialName("id") val id: String, /** The id of the thread for which this contact applies. */ - @SerializedName("threadIdOnExternalPlatform") + @SerialName("threadIdOnExternalPlatform") + @Contextual val threadIdOnExternalPlatform: UUID, - @SerializedName("status") + @SerialName("status") val status: ContactStatus, - @SerializedName("createdAt") - val createdAt: Date, + @SerialName("createdAt") + val createdAt: DateAsString, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldModel.kt index d4963b80..addcc1f7 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldModel.kt @@ -15,16 +15,20 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.thread.CustomField +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Date +@Serializable internal data class CustomFieldModel( - @SerializedName("ident") + @SerialName("ident") val id: String, - @SerializedName("value") + @SerialName("value") val value: String, - @SerializedName("updatedAt") + @SerialName("updatedAt") + @Contextual val updatedAt: Date, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldPolyType.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldPolyType.kt index c162918d..f0007d12 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldPolyType.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomFieldPolyType.kt @@ -15,41 +15,52 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal sealed interface CustomFieldPolyType { + @Serializable + @SerialName("text") data class Text( - @SerializedName("ident") + @SerialName("ident") val fieldId: String, - @SerializedName("label") + @SerialName("label") val label: String, ) : CustomFieldPolyType + @Serializable + @SerialName("email") data class Email( - @SerializedName("ident") + @SerialName("ident") val fieldId: String, - @SerializedName("label") + @SerialName("label") val label: String, ) : CustomFieldPolyType + @Serializable + @SerialName("list") data class Selector( - @SerializedName("ident") + @SerialName("ident") val fieldId: String, - @SerializedName("label") + @SerialName("label") val label: String, - @SerializedName("values") + @SerialName("values") val values: List, ) : CustomFieldPolyType + @Serializable + @SerialName("tree") data class Hierarchy( - @SerializedName("ident") + @SerialName("ident") val fieldId: String, - @SerializedName("label") + @SerialName("label") val label: String, - @SerializedName("values") + @SerialName("values") val values: List, ) : CustomFieldPolyType - object Noop : CustomFieldPolyType + @Serializable + data object Noop : CustomFieldPolyType } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomerIdentityModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomerIdentityModel.kt index e79554c1..663a1e45 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomerIdentityModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/CustomerIdentityModel.kt @@ -15,20 +15,22 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.message.MessageAuthor +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class CustomerIdentityModel( - @SerializedName("idOnExternalPlatform") + @SerialName("idOnExternalPlatform") val idOnExternalPlatform: String, - @SerializedName("firstName") + @SerialName("firstName") val firstName: String? = null, - @SerializedName("lastName") + @SerialName("lastName") val lastName: String? = null, - @SerializedName("image") + @SerialName("image") val imageUrl: String? = null, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ErrorModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ErrorModel.kt index f7293139..e3d197d4 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ErrorModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ErrorModel.kt @@ -15,16 +15,18 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.ErrorType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Model for error event pushed from server. * * @property error Details about the error. */ +@Serializable internal data class ErrorModel( - @SerializedName("error") + @SerialName("error") val error: Error, ) { /** @@ -34,10 +36,11 @@ internal data class ErrorModel( * @property transactionId Id of transaction which has triggered the error, usable for tracking down the cause in * server logs. */ + @Serializable internal data class Error( - @SerializedName("errorCode") + @SerialName("errorCode") val errorCode: ErrorType, - @SerializedName("transactionId") + @SerialName("transactionId") val transactionId: String, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageAuthorInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageAuthorInternal.kt index 38bf7dcd..4778b6d7 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageAuthorInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageAuthorInternal.kt @@ -22,6 +22,7 @@ internal data class MessageAuthorInternal( override val firstName: String, override val lastName: String, override val imageUrl: String?, + override val nickname: String? = null, ) : MessageAuthor() { override fun toString() = buildString { @@ -33,6 +34,8 @@ internal data class MessageAuthorInternal( append(lastName) append("', imageUrl='") append(imageUrl) + append("', nickname='") + append(nickname) append("')") } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageDirectionModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageDirectionModel.kt index a85f63bc..4d6d5da9 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageDirectionModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageDirectionModel.kt @@ -15,24 +15,26 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.message.MessageDirection +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Message direction from backend POV. */ +@Serializable internal enum class MessageDirectionModel(val value: String) { /** * Message inbound to the backend - usually from client. */ - @SerializedName("inbound") + @SerialName("inbound") ToAgent("inbound"), /** * Message outbound from backend - either from agent or bot. */ - @SerializedName("outbound") + @SerialName("outbound") ToClient("outbound"); fun toMessageDirection() = when (this) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageModel.kt index 37543260..06a9dc2c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageModel.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.MessageDirectionModel.ToAgent import com.nice.cxonechat.internal.model.MessageDirectionModel.ToClient import com.nice.cxonechat.internal.model.network.MessagePolyContent @@ -26,35 +25,42 @@ import com.nice.cxonechat.internal.model.network.MessagePolyContent.RichLink import com.nice.cxonechat.internal.model.network.MessagePolyContent.Text import com.nice.cxonechat.internal.model.network.UserStatistics import com.nice.cxonechat.message.MessageAuthor +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Date import java.util.UUID +@Serializable internal data class MessageModel( - @SerializedName("idOnExternalPlatform") + @SerialName("idOnExternalPlatform") + @Contextual val idOnExternalPlatform: UUID, - @SerializedName("threadIdOnExternalPlatform") + @SerialName("threadIdOnExternalPlatform") + @Contextual val threadIdOnExternalPlatform: UUID, - @SerializedName("messageContent") + @SerialName("messageContent") val messageContent: MessagePolyContent, - @SerializedName("createdAt") + @SerialName("createdAt") + @Contextual val createdAt: Date, - @SerializedName("attachments") + @SerialName("attachments") val attachments: List, - @SerializedName("direction") + @SerialName("direction") val direction: MessageDirectionModel, - @SerializedName("userStatistics") + @SerialName("userStatistics") val userStatistics: UserStatistics, - @SerializedName("authorUser") + @SerialName("authorUser") val authorUser: AgentModel? = null, - @SerializedName("authorEndUserIdentity") + @SerialName("authorEndUserIdentity") val authorEndUserIdentity: CustomerIdentityModel? = null, ) { val author: MessageAuthor? diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/NodeModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/NodeModel.kt index fb385151..b40c387f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/NodeModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/NodeModel.kt @@ -15,13 +15,15 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class NodeModel( - @SerializedName("name") + @SerialName("name") val name: String, - @SerializedName("value") + @SerialName("value") val value: String, - @SerializedName("parentId") + @SerialName("parentId") val parentId: String?, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactCustomFieldDefinitionModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactCustomFieldDefinitionModel.kt index adc0aa92..444d29dc 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactCustomFieldDefinitionModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactCustomFieldDefinitionModel.kt @@ -15,11 +15,13 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class PreContactCustomFieldDefinitionModel( - @SerializedName("isRequired") + @SerialName("isRequired") val isRequired: Boolean, - @SerializedName("definition") + @SerialName("definition") val definition: CustomFieldPolyType, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactFormModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactFormModel.kt index e9330901..6a954526 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactFormModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PreContactFormModel.kt @@ -15,19 +15,21 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.prechat.PreChatSurvey import com.nice.cxonechat.prechat.PreChatSurveyInternal import com.nice.cxonechat.state.FieldDefinitionImpl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class PreContactFormModel( - @SerializedName("name") + @SerialName("name") val name: String, - @SerializedName("channels") + @SerialName("channels") val channels: List, - @SerializedName("customFields") + @SerialName("customFields") val customFields: List, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/SelectorModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/SelectorModel.kt index ef90fc84..4b1a0821 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/SelectorModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/SelectorModel.kt @@ -15,11 +15,13 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class SelectorModel( - @SerializedName("name") + @SerialName("name") val name: String, - @SerializedName("value") + @SerialName("value") val label: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Thread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Thread.kt index e65e241a..f0d81393 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Thread.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Thread.kt @@ -15,20 +15,24 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID // ThreadView /** Represents info about a thread from the socket. */ +@Serializable internal data class Thread( /** The unique id for the thread. */ - @SerializedName("idOnExternalPlatform") + @SerialName("idOnExternalPlatform") + @Contextual val idOnExternalPlatform: UUID, /** The name given to the thread (for multi-chat channels only). */ - @SerializedName("threadName") + @SerialName("threadName") val threadName: String? = null, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Visitor.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Visitor.kt index 5d5f5258..ba223b53 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Visitor.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/Visitor.kt @@ -15,25 +15,27 @@ package com.nice.cxonechat.internal.model -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.network.CustomVariable import com.nice.cxonechat.internal.model.network.DeviceFingerprint import com.nice.cxonechat.internal.model.network.Journey import com.nice.cxonechat.state.Connection +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable // Visitor /** * All information about a visitor. */ +@Serializable internal data class Visitor( - @SerializedName("customerIdentity") + @SerialName("customerIdentity") val customerIdentity: CustomerIdentityModel? = null, - @SerializedName("browserFingerprint") + @SerialName("browserFingerprint") val deviceFingerprint: DeviceFingerprint, - @SerializedName("journey") + @SerialName("journey") val journey: Journey? = null, - @SerializedName("customVariables") + @SerialName("customVariables") val customVariables: List? = null, ) { constructor( diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessPayload.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessPayload.kt index 23b4bba4..7fa8821a 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessPayload.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessPayload.kt @@ -15,10 +15,12 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class AccessPayload( - @SerializedName("accessToken") + @SerialName("accessToken") val accessToken: AccessTokenPayload?, ) { constructor(token: String?) : this(token?.let(::AccessTokenPayload)) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessToken.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessToken.kt index 18c73299..bf1699c3 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessToken.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessToken.kt @@ -15,8 +15,10 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.util.plus +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import java.util.Date import kotlin.time.Duration.Companion.seconds @@ -24,14 +26,18 @@ import kotlin.time.Duration.Companion.seconds * An access token used by the customer for sending messages if OAuth authorization is on for the * channel. */ +@Serializable internal data class AccessToken( - @SerializedName("token") + @SerialName("token") val token: String, - @SerializedName("expiresIn") + @SerialName("expiresIn") private val expiresIn: Long, ) { + @Transient private val createdAt = Date() + + @Transient val expiresAt = createdAt + expiresIn.seconds.inWholeMilliseconds /** Whether the token has expired or not. */ diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessTokenPayload.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessTokenPayload.kt index 71e1a955..c2cf4c36 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessTokenPayload.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessTokenPayload.kt @@ -15,9 +15,11 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class AccessTokenPayload constructor( - @SerializedName("token") + @SerialName("token") val token: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionArchiveThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionArchiveThread.kt index c67c232c..f754efbc 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionArchiveThread.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionArchiveThread.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.ArchiveThread @@ -23,14 +22,19 @@ import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionArchiveThread( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID, - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionAuthorizeCustomer.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionAuthorizeCustomer.kt index 98c141bf..e2829978 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionAuthorizeCustomer.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionAuthorizeCustomer.kt @@ -15,19 +15,24 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import com.nice.cxonechat.core.BuildConfig import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventType.AuthorizeCustomer import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionAuthorizeCustomer( - @SerializedName("action") + @SerialName("action") val action: EventAction = EventAction.Register, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { @@ -43,9 +48,16 @@ internal data class ActionAuthorizeCustomer( ) ) + @Serializable data class Data( - @SerializedName("authorization") + @SerialName("authorization") val authorization: OAuth, + @SerialName("disableChannelInfo") + val disableChannelInfo: Boolean = true, + @SerialName("sdkPlatform") + val platform: String = "android", + @SerialName("sdkVersion") + val version: String = BuildConfig.VERSION_NAME, ) { constructor( @@ -59,10 +71,11 @@ internal data class ActionAuthorizeCustomer( ) } + @Serializable data class OAuth( - @SerializedName("authorizationCode") + @SerialName("authorizationCode") val code: String?, - @SerializedName("codeVerifier") + @SerialName("codeVerifier") val verifier: String?, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionCustomerTyping.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionCustomerTyping.kt index 9358a5ce..db441ddf 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionCustomerTyping.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionCustomerTyping.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType @@ -25,14 +24,19 @@ import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionCustomerTyping( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { @@ -50,8 +54,9 @@ internal data class ActionCustomerTyping( ) ) + @Serializable data class Data( - @SerializedName("thread") + @SerialName("thread") val thread: Thread, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionEndContact.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionEndContact.kt index d4eee497..f429912c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionEndContact.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionEndContact.kt @@ -15,20 +15,24 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.EndContact import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionEndContact( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUID.randomUUID(), - @SerializedName("payload") + @SerialName("payload") val payload: LegacyPayload, ) { constructor( diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionExecuteTrigger.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionExecuteTrigger.kt index d70c68b0..1d710dea 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionExecuteTrigger.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionExecuteTrigger.kt @@ -15,19 +15,23 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventType.ExecuteTrigger import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionExecuteTrigger( - @SerializedName("action") + @SerialName("action") val action: EventAction = EventAction.ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: LegacyPayload, ) { @@ -46,8 +50,9 @@ internal data class ActionExecuteTrigger( ) ) + @Serializable data class Data( - @SerializedName("trigger") + @SerialName("trigger") val trigger: Identifier, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionFetchThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionFetchThread.kt index 3cc88ce0..a0449578 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionFetchThread.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionFetchThread.kt @@ -15,20 +15,24 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.FetchThreadList import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionFetchThread( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadMoreMessages.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadMoreMessages.kt index 5c52dfb6..a8c79015 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadMoreMessages.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadMoreMessages.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.LoadMoreMessages @@ -24,14 +23,19 @@ import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.DateTime import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionLoadMoreMessages( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { @@ -50,12 +54,14 @@ internal data class ActionLoadMoreMessages( ) ) + @Serializable data class Data( - @SerializedName("scrollToken") + @SerialName("scrollToken") val scrollToken: String, - @SerializedName("thread") + @SerialName("thread") val thread: Thread, - @SerializedName("oldestMessageDatetime") + @SerialName("oldestMessageDatetime") + @Contextual val oldestMessageDatetime: DateTime, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadThreadMetadata.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadThreadMetadata.kt index b873fb79..9fe6bd29 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadThreadMetadata.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionLoadThreadMetadata.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.LoadThreadMetadata @@ -23,14 +22,19 @@ import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionLoadThreadMetadata( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessage.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessage.kt index e80c9829..9e4908b0 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessage.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessage.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.SendMessage @@ -25,14 +24,21 @@ import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import java.util.UUID -internal data class ActionMessage constructor( - @SerializedName("action") +@Serializable +internal data class ActionMessage( + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { @@ -61,22 +67,27 @@ internal data class ActionMessage constructor( ) ) + @OptIn(ExperimentalSerializationApi::class) + @Serializable data class Data( - @SerializedName("thread") + @SerialName("thread") val thread: Thread, - @SerializedName("messageContent") + @SerialName("messageContent") val messageContent: MessageContent, - @SerializedName("idOnExternalPlatform") + @SerialName("idOnExternalPlatform") + @Contextual val id: UUID, - @SerializedName(value = "customer", alternate = ["consumer"]) + @SerialName(value = "customer") + @JsonNames("customer", "consumer") val customer: CustomFieldsData? = null, - @SerializedName(value = "contact", alternate = ["consumerContact"]) + @SerialName(value = "contact") + @JsonNames("contact", "consumerContact") val customerContact: CustomFieldsData?, - @SerializedName("attachments") + @SerialName("attachments") val attachments: List = emptyList(), - @SerializedName("deviceFingerprint") + @SerialName("deviceFingerprint") val deviceFingerprint: DeviceFingerprint = DeviceFingerprint(), - @SerializedName("accessToken") + @SerialName("accessToken") val accessToken: AccessTokenPayload? = null, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessageSeenByCustomer.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessageSeenByCustomer.kt index cc7c0c80..20fb8e2f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessageSeenByCustomer.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionMessageSeenByCustomer.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.MessageSeenByCustomer @@ -23,14 +22,19 @@ import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionMessageSeenByCustomer( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { @@ -45,8 +49,9 @@ internal data class ActionMessageSeenByCustomer( ) ) + @Serializable data class Data( - @SerializedName("thread") + @SerialName("thread") val thread: Thread, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionOutboundMessage.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionOutboundMessage.kt index 8351efdd..45a4dbb6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionOutboundMessage.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionOutboundMessage.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.SendOutbound @@ -26,14 +25,21 @@ import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.thread.CustomField import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import java.util.UUID +@Serializable internal data class ActionOutboundMessage( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: LegacyPayload, ) { @@ -60,22 +66,27 @@ internal data class ActionOutboundMessage( ) ) + @OptIn(ExperimentalSerializationApi::class) + @Serializable data class LegacyData( - @SerializedName("thread") + @SerialName("thread") val thread: Thread, - @SerializedName("messageContent") + @SerialName("messageContent") val messageContent: MessageContent, - @SerializedName("idOnExternalPlatform") + @SerialName("idOnExternalPlatform") + @Contextual val id: UUID, - @SerializedName("consumer", alternate = ["customer"]) + @SerialName("consumer") + @JsonNames("consumer", "customer") val customer: CustomFieldsData? = null, - @SerializedName("consumerContact", alternate = ["contact"]) + @SerialName("consumerContact") + @JsonNames("consumerContact", "contact") val customerContact: CustomFieldsData?, - @SerializedName("attachments") + @SerialName("attachments") val attachments: List = emptyList(), - @SerializedName("browserFingerprint") + @SerialName("browserFingerprint") val deviceFingerprint: DeviceFingerprint = DeviceFingerprint(), - @SerializedName("accessToken") + @SerialName("accessToken") val accessToken: AccessTokenPayload? = null, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionReconnectCustomer.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionReconnectCustomer.kt index 50ddd5d3..1a765b59 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionReconnectCustomer.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionReconnectCustomer.kt @@ -15,19 +15,23 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventType.ReconnectCustomer import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionReconnectCustomer( - @SerializedName("action") + @SerialName("action") val action: EventAction = EventAction.ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: LegacyPayload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverLiveChat.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverLiveChat.kt index 6008d600..312924bb 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverLiveChat.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverLiveChat.kt @@ -15,19 +15,23 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.RecoverLivechat import com.nice.cxonechat.state.Connection +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionRecoverLiveChat( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUID.randomUUID(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverThread.kt index 0926a9d8..6863074c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverThread.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRecoverThread.kt @@ -15,20 +15,24 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.RecoverThread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionRecoverThread( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRefreshToken.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRefreshToken.kt index 12e1c67b..cc1466c1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRefreshToken.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionRefreshToken.kt @@ -15,20 +15,24 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.RefreshToken import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionRefreshToken( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: LegacyPayload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetContactCustomFields.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetContactCustomFields.kt index 6b262b25..4110f584 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetContactCustomFields.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetContactCustomFields.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventType.SetContactCustomFields import com.nice.cxonechat.internal.model.CustomFieldModel @@ -23,14 +22,19 @@ import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionSetContactCustomFields( - @SerializedName("action") + @SerialName("action") val action: EventAction = EventAction.ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { @@ -50,12 +54,13 @@ internal data class ActionSetContactCustomFields( ) ) + @Serializable data class Data( - @SerializedName("thread") + @SerialName("thread") val thread: Thread, - @SerializedName("customFields") + @SerialName("customFields") val customFields: List, - @SerializedName("contact") + @SerialName("contact") val contact: Identifier, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetCustomerCustomFields.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetCustomerCustomFields.kt index 53a0de13..10ea7f11 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetCustomerCustomFields.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionSetCustomerCustomFields.kt @@ -15,21 +15,25 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.SetCustomerCustomFields import com.nice.cxonechat.internal.model.CustomFieldModel import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionSetCustomerCustomFields( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { @@ -44,8 +48,9 @@ internal data class ActionSetCustomerCustomFields( ) ) + @Serializable data class Data( - @SerializedName("customFields") + @SerialName("customFields") val customFields: List, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionStoreVisitorEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionStoreVisitorEvent.kt index 33e8b0d4..dc24a4dc 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionStoreVisitorEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionStoreVisitorEvent.kt @@ -15,22 +15,27 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.StoreVisitorEvents import com.nice.cxonechat.enums.VisitorEventType import com.nice.cxonechat.state.Connection import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement import java.util.Date import java.util.UUID +@Serializable internal data class ActionStoreVisitorEvent( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: LegacyPayload, ) { @@ -53,7 +58,7 @@ internal data class ActionStoreVisitorEvent( connection: Connection, visitor: UUID, destination: UUID, - vararg events: Pair, + vararg events: Pair, createdAt: Date = Date(), ) : this( payload = LegacyPayload( @@ -74,8 +79,9 @@ internal data class ActionStoreVisitorEvent( ) ) - data class Data constructor( - @SerializedName("visitorEvents") + @Serializable + data class Data( + @SerialName("visitorEvents") val visitorEvents: List, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionUpdateThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionUpdateThread.kt index fc80f048..d517b41a 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionUpdateThread.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ActionUpdateThread.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventAction import com.nice.cxonechat.enums.EventAction.ChatWindowEvent import com.nice.cxonechat.enums.EventType.UpdateThread @@ -23,14 +22,19 @@ import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class ActionUpdateThread( - @SerializedName("action") + @SerialName("action") val action: EventAction = ChatWindowEvent, - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("payload") + @SerialName("payload") val payload: Payload, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ContactFieldData.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ContactFieldData.kt index c00cd3a7..d2f85ba6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ContactFieldData.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ContactFieldData.kt @@ -15,12 +15,29 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import com.nice.cxonechat.enums.ContactStatus import com.nice.cxonechat.internal.model.CustomFieldModel +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Date +import java.util.UUID +@Serializable internal data class ContactFieldData( - @SerializedName("id") + @SerialName("id") val id: String, - @SerializedName("customFields") + @SerialName("customFields") val customFields: List, + /** The id of the thread for which this contact applies. */ + @SerialName("threadIdOnExternalPlatform") + @Contextual + val threadIdOnExternalPlatform: UUID, + + @SerialName("status") + val status: ContactStatus, + + @SerialName("createdAt") + @Contextual + val createdAt: Date, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Conversion.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Conversion.kt index c8416917..3197af1b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Conversion.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Conversion.kt @@ -15,14 +15,18 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Date -internal data class Conversion constructor( - @SerializedName("conversionType") +@Serializable +internal data class Conversion( + @SerialName("conversionType") val type: String, - @SerializedName("conversionValue") - val value: Number, - @SerializedName("conversionTimeWithMilliseconds") + @SerialName("conversionValue") + val value: Long, + @SerialName("conversionTimeWithMilliseconds") + @Contextual val timestamp: Date, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomFieldsData.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomFieldsData.kt index e60f1f0a..c7d77e2d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomFieldsData.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomFieldsData.kt @@ -15,10 +15,12 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.CustomFieldModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class CustomFieldsData( - @SerializedName("customFields") + @SerialName("customFields") val customFields: List, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomVariable.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomVariable.kt index 343ae805..c7388ac4 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomVariable.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/CustomVariable.kt @@ -15,11 +15,13 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -internal data class CustomVariable constructor( - @SerializedName("identifier") +@Serializable +internal data class CustomVariable( + @SerialName("identifier") val identifier: String, - @SerializedName("value") + @SerialName("value") val value: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/DeviceFingerprint.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/DeviceFingerprint.kt index a26882a5..42054fee 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/DeviceFingerprint.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/DeviceFingerprint.kt @@ -16,41 +16,43 @@ package com.nice.cxonechat.internal.model.network import android.os.Build -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Locale /** Represents fingerprint data about the customer. */ +@Serializable internal data class DeviceFingerprint( - @SerializedName("country") + @SerialName("country") val country: String? = Locale.getDefault().country, /** Current IP Address. */ - @SerializedName("ip") + @SerialName("ip") val ip: String? = null, - @SerializedName("language") + @SerialName("language") val language: String? = Locale.getDefault().language, - @SerializedName("location") + @SerialName("location") val location: String? = null, /** The type of application the customer is using (native or web app). */ - @SerializedName("applicationType") + @SerialName("applicationType") val applicationType: String? = "native", /** The operating system the customer is currently using. */ - @SerializedName("os") + @SerialName("os") val os: String? = "Android", /** The operating system version that the customer is currently using. */ - @SerializedName("osVersion") + @SerialName("osVersion") val osVersion: String? = Build.VERSION.RELEASE, /** The type of device that the customer is currently using. */ - @SerializedName("deviceType") + @SerialName("deviceType") val deviceType: String? = "mobile", /** Token uniquely identifying this device. This defaults to null since it may be considered PII. */ - @SerializedName("deviceToken") + @SerialName("deviceToken") val deviceToken: String? = null, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EndContactPayload.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EndContactPayload.kt index c4863c09..8b20297c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EndContactPayload.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EndContactPayload.kt @@ -15,17 +15,20 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -internal class EndContactPayload( - thread: ChatThread, +@Serializable +internal data class EndContactPayload( + @SerialName("thread") + val thread: Thread, + @SerialName("contact") + val contact: Identifier?, ) { - - @SerializedName("thread") - val thread = Thread(thread) - - @SerializedName("contact") - val contact = thread.contactId?.let(::Identifier) + constructor(thread: ChatThread) : this( + thread = Thread(thread), + contact = thread.contactId?.let(::Identifier) + ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventAgentTyping.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventAgentTyping.kt index 6b0a428a..a5aa3af0 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventAgentTyping.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventAgentTyping.kt @@ -15,16 +15,18 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.SenderTypingStarted import com.nice.cxonechat.internal.model.AgentModel import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** Event received when the agent begins typing or stops typing. */ +@Serializable internal data class EventAgentTyping( - @SerializedName("data") + @SerialName("data") val data: Data, ) { @@ -37,10 +39,11 @@ internal data class EventAgentTyping( fun inThread(thread: ChatThread) = data.thread.idOnExternalPlatform == thread.id + @Serializable data class Data( - @SerializedName("thread") + @SerialName("thread") val thread: Thread, - @SerializedName("user") + @SerialName("user") val user: AgentModel?, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCaseStatusChanged.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCaseStatusChanged.kt index 0ed67e0d..a6a1aa3f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCaseStatusChanged.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCaseStatusChanged.kt @@ -15,20 +15,26 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.CaseStatusChanged import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.util.DateTime import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class EventCaseStatusChanged( - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID = UUIDProvider.next(), - @SerializedName("createdAt") + @SerialName("createdAt") + @Contextual val createdAt: DateTime, - @SerializedName("data") + @SerialName("data") + @Contextual val data: Data, ) { @@ -38,43 +44,47 @@ internal data class EventCaseStatusChanged( fun inThread(thread: ChatThread) = data.case.threadIdOnExternalPlatform == thread.id.toString() + @Serializable internal data class Data( - @SerializedName("case") + @SerialName("case") val case: Case, ) + @Serializable internal data class Case( - @SerializedName("threadIdOnExternalPlatform") + @SerialName("threadIdOnExternalPlatform") val threadIdOnExternalPlatform: String, - @SerializedName("status") + @SerialName("status") val status: CaseStatus, - @SerializedName("statusUpdatedAt") + @SerialName("statusUpdatedAt") + @Contextual val statusUpdatedAt: DateTime, ) + @Serializable internal enum class CaseStatus { - @SerializedName("new") + @SerialName("new") New, - @SerializedName("open") + @SerialName("open") Open, - @SerializedName("pending") + @SerialName("pending") Pending, - @SerializedName("escalated") + @SerialName("escalated") Escalated, - @SerializedName("resolved") + @SerialName("resolved") Resolved, /** * This state is terminal. */ - @SerializedName("closed") + @SerialName("closed") Closed, - @SerializedName("trashed") + @SerialName("trashed") Trashed } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventContactInboxAssigneeChanged.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventContactInboxAssigneeChanged.kt index b4091c0e..624ab99b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventContactInboxAssigneeChanged.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventContactInboxAssigneeChanged.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.CaseInboxAssigneeChanged import com.nice.cxonechat.internal.model.AgentModel import com.nice.cxonechat.internal.model.Brand @@ -23,9 +22,12 @@ import com.nice.cxonechat.internal.model.ChannelIdentifier import com.nice.cxonechat.internal.model.Contact import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class EventContactInboxAssigneeChanged( - @SerializedName("data") + @SerialName("data") val data: Data, ) { @@ -35,16 +37,17 @@ internal data class EventContactInboxAssigneeChanged( fun inThread(thread: ChatThread) = case.threadIdOnExternalPlatform == thread.id + @Serializable data class Data( - @SerializedName("brand") + @SerialName("brand") val brand: Brand, - @SerializedName("channel") + @SerialName("channel") val channel: ChannelIdentifier, - @SerializedName("case") + @SerialName("case") val case: Contact, - @SerializedName("inboxAssignee") + @SerialName("inboxAssignee") val inboxAssignee: AgentModel?, - @SerializedName("previousInboxAssignee") + @SerialName("previousInboxAssignee") val previousInboxAssignee: AgentModel?, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCustomerAuthorized.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCustomerAuthorized.kt index cb27ab18..a23aa63f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCustomerAuthorized.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCustomerAuthorized.kt @@ -15,14 +15,16 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.CustomerAuthorized import com.nice.cxonechat.internal.model.CustomerIdentityModel import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** Event received when a customer is successfully authorized. */ +@Serializable internal data class EventCustomerAuthorized( - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) { @@ -33,10 +35,11 @@ internal data class EventCustomerAuthorized( val token get() = postback.data.accessToken?.token val tokenExpiresAt get() = postback.data.accessToken?.expiresAt + @Serializable data class Data( - @SerializedName("consumerIdentity") + @SerialName("consumerIdentity") val consumerIdentity: CustomerIdentityModel, - @SerializedName("accessToken") + @SerialName("accessToken") val accessToken: AccessToken?, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventInS3.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventInS3.kt index fd465db1..20c9172f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventInS3.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventInS3.kt @@ -15,35 +15,44 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.util.IsoDate +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class EventInS3( - @SerializedName("eventId") + @SerialName("eventId") + @Contextual val eventId: UUID, - @SerializedName("createdAt") + @SerialName("createdAt") + @Contextual val createdAt: IsoDate, - @SerializedName("data") + @SerialName("data") + @Contextual val data: Data, ) { + @Serializable internal data class Data( - @SerializedName("s3Object") + @SerialName("s3Object") val s3Object: S3Object, - @SerializedName("originEvent") + @SerialName("originEvent") val originEvent: OriginEvent ) + @Serializable internal data class S3Object( - @SerializedName("url") + @SerialName("url") val url: String ) + @Serializable internal data class OriginEvent( - @SerializedName("eventType") + @SerialName("eventType") val eventType: EventType ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventLiveChatThreadRecovered.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventLiveChatThreadRecovered.kt index 184a1670..d49a2f88 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventLiveChatThreadRecovered.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventLiveChatThreadRecovered.kt @@ -15,14 +15,18 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.AgentModel import com.nice.cxonechat.internal.model.CustomFieldModel import com.nice.cxonechat.internal.model.MessageModel import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames +@Serializable internal data class EventLiveChatThreadRecovered( - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) { @@ -38,22 +42,26 @@ internal data class EventLiveChatThreadRecovered( ) val scrollToken get() = data.messagesScrollToken val customerCustomFields get() = data.customer?.customFields.orEmpty().map(CustomFieldModel::toCustomField) + val lastContactStatus get() = data.contact?.status fun inThread(thread: ChatThread) = thread.id == this.thread?.id && messages.all { it.threadId == thread.id } + @OptIn(ExperimentalSerializationApi::class) + @Serializable data class Data( - @SerializedName("messages") - val messages: List?, - @SerializedName("inboxAssignee") - val inboxAssignee: AgentModel?, - @SerializedName("thread") - val thread: ReceivedThreadData?, - @SerializedName("messagesScrollToken") + @SerialName("messages") + val messages: List? = null, + @SerialName("inboxAssignee") + val inboxAssignee: AgentModel? = null, + @SerialName("thread") + val thread: ReceivedThreadData? = null, + @SerialName("messagesScrollToken") val messagesScrollToken: String, - @SerializedName("customer") + @SerialName("customer") val customer: CustomFieldsData? = null, - @SerializedName("contact", alternate = ["consumerContact"]) + @SerialName("contact") + @JsonNames("contact", "consumerContact") val contact: ContactFieldData? = null, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageCreated.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageCreated.kt index 62c9750e..b5353a0a 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageCreated.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageCreated.kt @@ -15,17 +15,19 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.MessageCreated import com.nice.cxonechat.internal.model.Contact import com.nice.cxonechat.internal.model.MessageModel import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** Event Received when a message has been successfully sent/created. */ +@Serializable internal data class EventMessageCreated( - @SerializedName("data") + @SerialName("data") val data: Data, ) { @@ -39,12 +41,13 @@ internal data class EventMessageCreated( fun inThread(thread: ChatThread): Boolean = thread.id == threadId + @Serializable data class Data( - @SerializedName("case") + @SerialName("case") val case: Contact, - @SerializedName("thread") + @SerialName("thread") val thread: Thread, - @SerializedName("message") + @SerialName("message") val message: MessageModel, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageReadByAgent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageReadByAgent.kt index 419fdeaf..c772f262 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageReadByAgent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMessageReadByAgent.kt @@ -15,17 +15,19 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.MessageReadChanged import com.nice.cxonechat.internal.model.MessageModel import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Event received when an agent has read a message. */ +@Serializable internal data class EventMessageReadByAgent( - @SerializedName("data") + @SerialName("data") val data: Data, ) { @@ -44,8 +46,9 @@ internal data class EventMessageReadByAgent( threadMessage.id == messageId } + @Serializable data class Data( - @SerializedName("message") + @SerialName("message") val message: MessageModel, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMoreMessagesLoaded.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMoreMessagesLoaded.kt index 2a42cb59..2dea7c5e 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMoreMessagesLoaded.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventMoreMessagesLoaded.kt @@ -15,14 +15,16 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.MoreMessagesLoaded import com.nice.cxonechat.internal.model.MessageModel import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class EventMoreMessagesLoaded( - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) { @@ -31,10 +33,11 @@ internal data class EventMoreMessagesLoaded( fun inThread(thread: ChatThread) = messages.all { it.threadId == thread.id } + @Serializable data class Data( - @SerializedName("messages") + @SerialName("messages") val messages: List, - @SerializedName("scrollToken") + @SerialName("scrollToken") val scrollToken: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventProactiveAction.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventProactiveAction.kt index 8bc8bd09..7f785757 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventProactiveAction.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventProactiveAction.kt @@ -15,17 +15,24 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.analytics.ActionMetadataInternal import com.nice.cxonechat.enums.ActionType import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.internal.model.CustomFieldModel +import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import java.util.UUID +@Serializable internal data class EventProactiveAction( - @SerializedName("data") + @SerialName("data") val data: Data, ) { @@ -37,26 +44,36 @@ internal data class EventProactiveAction( val headlineSecondaryText get() = data.proactiveAction.action.data.content.headlineSecondaryText val image get() = data.proactiveAction.action.data.content.image val mimeType get() = data.proactiveAction.action.data.content.mimeType - val variables get() = data.proactiveAction.action.data.content.variables + val variables get() = data.proactiveAction.action.data.content.variables.orEmpty().mapValues { entry -> + when(val element = entry.value) { + null -> null + is JsonPrimitive -> element.content + else -> Default.serializer.encodeToString(element) + } + } + @Serializable data class Data( - @SerializedName("proactiveAction") + @SerialName("proactiveAction") val proactiveAction: Action, ) + @Serializable data class Action( - @SerializedName("action") + @SerialName("action") val action: ProactiveActionDetails, ) - data class ProactiveActionDetails constructor( - @SerializedName("actionId") + @Serializable + data class ProactiveActionDetails( + @SerialName("actionId") + @Contextual val actionId: UUID, - @SerializedName("actionName") + @SerialName("actionName") val actionName: String, - @SerializedName("actionType") + @SerialName("actionType") val actionType: ActionType, - @SerializedName("data") + @SerialName("data") val data: ActionData, ) { @@ -67,30 +84,33 @@ internal data class EventProactiveAction( ) } + @Serializable data class ActionData( - @SerializedName("content") + @SerialName("content") val content: Content, - @SerializedName("handover") + @SerialName("handover") val handover: Handover, ) + @Serializable data class Content( - @SerializedName("bodyText") + @SerialName("bodyText") val bodyText: String, - @SerializedName("headlineText") + @SerialName("headlineText") val headlineText: String? = null, - @SerializedName("headlineSecondaryText") + @SerialName("headlineSecondaryText") val headlineSecondaryText: String? = null, - @SerializedName("image") + @SerialName("image") val image: String? = null, - @SerializedName("mimeType") + @SerialName("mimeType") val mimeType: String? = null, - @SerializedName("variables") - val variables: Map? = null, + @SerialName("variables") + val variables: Map? = null, ) + @Serializable data class Handover( - @SerializedName("customFields") + @SerialName("customFields") val customFields: List? = null, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventSetPositionInQueue.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventSetPositionInQueue.kt index b859df7a..ef52dd95 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventSetPositionInQueue.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventSetPositionInQueue.kt @@ -15,11 +15,12 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName -import com.nice.cxonechat.internal.model.Contact +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class EventSetPositionInQueue( - @SerializedName("data") + @SerialName("data") val data: Data ) { val consumerContact get() = data.consumerContact.id @@ -27,18 +28,20 @@ internal data class EventSetPositionInQueue( val positionInQueue get() = data.positionInQueue val hasOnlineAgent get() = data.isAnyAgentOnlineForQueue + @Serializable data class Data( - @SerializedName("consumerContact") - val consumerContact: Contact, - @SerializedName("routingQueue") + @SerialName("consumerContact") + val consumerContact: Identifier, + @SerialName("routingQueue") val routingQueue: RoutingQueue, - @SerializedName("positionInQueue") + @SerialName("positionInQueue") val positionInQueue: Int, - @SerializedName("isAnyAgentOnlineForQueue") + @SerialName("isAnyAgentOnlineForQueue") val isAnyAgentOnlineForQueue: Boolean, ) { + @Serializable data class RoutingQueue( - @SerializedName("id") + @SerialName("id") val id: String, ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadArchived.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadArchived.kt index d1154746..c9844b96 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadArchived.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadArchived.kt @@ -15,20 +15,25 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.internal.socket.EventCallback.EventWithId import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class EventThreadArchived( - @SerializedName("eventId") + @SerialName("eventId") + @Contextual override val eventId: UUID, - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) : EventWithId { + @Serializable data class Postback( - @SerializedName("eventType") + @SerialName("eventType") val eventType: String ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadListFetched.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadListFetched.kt index 86610304..085dffc8 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadListFetched.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadListFetched.kt @@ -15,19 +15,22 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.ThreadListFetched import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class EventThreadListFetched( - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) { val threads get() = postback.data.threads + @Serializable data class Data( - @SerializedName("threads") + @SerialName("threads") val threads: List, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadMetadataLoaded.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadMetadataLoaded.kt index 921d11ad..d1ec472b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadMetadataLoaded.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadMetadataLoaded.kt @@ -15,15 +15,17 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.ThreadMetadataLoaded import com.nice.cxonechat.internal.model.AgentModel import com.nice.cxonechat.internal.model.MessageModel import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class EventThreadMetadataLoaded( - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) { @@ -32,10 +34,11 @@ internal data class EventThreadMetadataLoaded( fun inThread(thread: ChatThread) = message?.threadId == thread.id + @Serializable data class Data( - @SerializedName("ownerAssignee") + @SerialName("ownerAssignee") val ownerAssignee: AgentModel? = null, - @SerializedName("lastMessage") + @SerialName("lastMessage") val lastMessage: MessageModel, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadRecovered.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadRecovered.kt index a73c00a2..1fde58fe 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadRecovered.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadRecovered.kt @@ -15,16 +15,19 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.ThreadRecovered import com.nice.cxonechat.internal.model.AgentModel import com.nice.cxonechat.internal.model.CustomFieldModel import com.nice.cxonechat.internal.model.MessageModel import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames +@Serializable internal data class EventThreadRecovered( - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) { @@ -43,18 +46,20 @@ internal data class EventThreadRecovered( fun inThread(thread: ChatThread) = this.thread.id == thread.id && messages.all { it.threadId == thread.id } + @Serializable data class Data( - @SerializedName("messages") + @SerialName("messages") val messages: List?, - @SerializedName("inboxAssignee") + @SerialName("inboxAssignee") val inboxAssignee: AgentModel?, - @SerializedName("thread") + @SerialName("thread") val thread: ReceivedThreadData, - @SerializedName("messagesScrollToken") + @SerialName("messagesScrollToken") val messagesScrollToken: String, - @SerializedName("customer") + @SerialName("customer") val customer: CustomFieldsData? = null, - @SerializedName("contact", alternate = ["consumerContact"]) + @SerialName("contact") + @JsonNames("contact", "consumerContact") val contact: ContactFieldData? = null, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadUpdated.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadUpdated.kt index 8435a628..9eaa531d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadUpdated.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventThreadUpdated.kt @@ -15,18 +15,23 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.ThreadUpdated import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent import com.nice.cxonechat.thread.ChatThread +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class EventThreadUpdated( - @SerializedName("postback") - val postback: Postback, + @SerialName("postback") + val postback: Postback, ) { + @Serializable data class Data( - @SerializedName("id") + @SerialName("id") + @Contextual val threadId: UUID ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventTokenRefreshed.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventTokenRefreshed.kt index 996a5a9f..2403e250 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventTokenRefreshed.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventTokenRefreshed.kt @@ -15,21 +15,24 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType.TokenRefreshed import com.nice.cxonechat.internal.socket.EventCallback.ReceivedEvent +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** Event received when a token has been successfully refreshed. */ +@Serializable internal data class EventTokenRefreshed( - @SerializedName("postback") + @SerialName("postback") val postback: Postback, ) { val token get() = postback.data.accessToken.token val expiresAt get() = postback.data.accessToken.expiresAt + @Serializable data class Data( - @SerializedName("accessToken") + @SerialName("accessToken") val accessToken: AccessToken, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Identifier.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Identifier.kt index 99dd9009..58950460 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Identifier.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Identifier.kt @@ -15,11 +15,13 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID +@Serializable internal data class Identifier( - @SerializedName("id") + @SerialName("id") val id: String, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Journey.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Journey.kt index 4aa735bf..e877e254 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Journey.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Journey.kt @@ -15,11 +15,13 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -internal data class Journey constructor( - @SerializedName("referrer") +@Serializable +internal data class Journey( + @SerialName("referrer") val referrer: Referrer, - @SerializedName("utm") + @SerialName("utm") val utm: UTM, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/LegacyPayload.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/LegacyPayload.kt index b20f00fa..8662796c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/LegacyPayload.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/LegacyPayload.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.internal.model.Brand import com.nice.cxonechat.internal.model.ChannelIdentifier @@ -24,22 +23,29 @@ import com.nice.cxonechat.internal.model.asBrand import com.nice.cxonechat.internal.model.asChannelId import com.nice.cxonechat.internal.model.asCustomerIdentity import com.nice.cxonechat.state.Connection +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import java.util.UUID +@OptIn(ExperimentalSerializationApi::class) +@Serializable internal data class LegacyPayload( - @SerializedName("brand") + @SerialName("brand") val brand: Brand, - @SerializedName("channel") + @SerialName("channel") val channel: ChannelIdentifier, - @SerializedName("data") + @SerialName("data") val data: Data, - @SerializedName("consumerIdentity", alternate = ["customerIdentity"]) + @SerialName("consumerIdentity") + @JsonNames("customerIdentity", "consumerIdentity") val customerIdentity: CustomerIdentityModel, - @SerializedName("visitor") + @SerialName("visitor") val visitor: Identifier?, - @SerializedName("destination") + @SerialName("destination") val destination: Identifier?, - @SerializedName("eventType") + @SerialName("eventType") val eventType: EventType, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MediaModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MediaModel.kt index b5ceba38..0805853b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MediaModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MediaModel.kt @@ -15,13 +15,15 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class MediaModel( - @SerializedName("fileName") + @SerialName("fileName") val fileName: String, - @SerializedName("url") + @SerialName("url") val url: String, - @SerializedName("mimeType") + @SerialName("mimeType") val mimeType: String ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessageContent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessageContent.kt index 16360ab5..a31d221d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessageContent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessageContent.kt @@ -15,10 +15,12 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.MessageContentType import com.nice.cxonechat.enums.MessageContentType.Text +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class MessageContent( /** * This message's type. It can have various types on @@ -26,7 +28,7 @@ internal data class MessageContent( * * @see MessageContentType * */ - @SerializedName("type") + @SerialName("type") val type: MessageContentType, /** @@ -36,7 +38,7 @@ internal data class MessageContent( * * @see MessageContentType * */ - @SerializedName("payload") + @SerialName("payload") val payload: MessagePayload, /** @@ -48,7 +50,7 @@ internal data class MessageContent( * should then send a message containing original [com.nice.cxonechat.message.Action.ReplyButton.postback], * so an automatic backend process can react to that selection. */ - @SerializedName("postback") + @SerialName("postback") val postback: String? = null, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePayload.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePayload.kt index d0b3089c..efec9ab7 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePayload.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePayload.kt @@ -15,9 +15,11 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class MessagePayload( - @SerializedName("text") + @SerialName("text") val text: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePolyContent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePolyContent.kt index 05b052fb..46d26572 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePolyContent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/MessagePolyContent.kt @@ -15,68 +15,87 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("type") internal sealed class MessagePolyContent { + @Serializable + @SerialName("TEXT") data class Text( - @SerializedName("payload") + @SerialName("payload") val payload: Payload, - @SerializedName("fallback") + @SerialName("fallback") val fallbackText: String? = null, ) : MessagePolyContent() { + @Serializable data class Payload( - @SerializedName("text") + @SerialName("text") val text: String, ) } + @Serializable + @SerialName("QUICK_REPLIES") data class QuickReplies( - @SerializedName("fallbackText") + @SerialName("fallbackText") val fallbackText: String, - @SerializedName("payload") + @SerialName("payload") val payload: Payload ) : MessagePolyContent() { + @Serializable data class Payload( - @SerializedName("text") + @SerialName("text") val text: WrappedText, - @SerializedName("actions") + @SerialName("actions") val actions: List ) } + @Serializable + @SerialName("LIST_PICKER") data class ListPicker( - @SerializedName("fallbackText") + @SerialName("fallbackText") val fallbackText: String?, - @SerializedName("payload") + @SerialName("payload") val payload: Payload ) : MessagePolyContent() { + @Serializable data class Payload( - @SerializedName("title") + @SerialName("title") val title: WrappedText, - @SerializedName("text") + @SerialName("text") val text: WrappedText, - @SerializedName("actions") + @SerialName("actions") val actions: List ) } + @Serializable + @SerialName("RICH_LINK") data class RichLink( - @SerializedName("fallbackText") + @SerialName("fallbackText") val fallbackText: String, - @SerializedName("payload") + @SerialName("payload") val payload: Payload ) : MessagePolyContent() { + @Serializable data class Payload( - @SerializedName("media") + @SerialName("media") val media: MediaModel, - @SerializedName("title") + @SerialName("title") val title: WrappedText, - @SerializedName("url") + @SerialName("url") val url: String ) } - object Noop : MessagePolyContent() + @Serializable + data object Noop : MessagePolyContent() } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PageViewData.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PageViewData.kt index 7c7c877b..4164c0c8 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PageViewData.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PageViewData.kt @@ -15,17 +15,19 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Data to be sent on a page view visitor event. */ -internal data class PageViewData constructor( +@Serializable +internal data class PageViewData( /** A title for the page that was viewed. */ - @SerializedName("title") + @SerialName("title") val title: String, /** The unique URL or URI for the page that was viewed. Doesn't need to be a valid URL. */ - @SerializedName("url") + @SerialName("url") val url: String, // This can be any identifier for the page; doesn't need to be URL ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Payload.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Payload.kt index 242f8066..4e686844 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Payload.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Payload.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.internal.model.Brand import com.nice.cxonechat.internal.model.ChannelIdentifier @@ -24,22 +23,29 @@ import com.nice.cxonechat.internal.model.asBrand import com.nice.cxonechat.internal.model.asChannelId import com.nice.cxonechat.internal.model.asCustomerIdentity import com.nice.cxonechat.state.Connection +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import java.util.UUID +@Serializable +@OptIn(ExperimentalSerializationApi::class) internal data class Payload( - @SerializedName("brand") + @SerialName("brand") val brand: Brand, - @SerializedName("channel") + @SerialName("channel") val channel: ChannelIdentifier, - @SerializedName("data") + @SerialName("data") val data: Data, - @SerializedName(value = "customerIdentity", alternate = ["consumerIdentity"]) + @SerialName(value = "customerIdentity") + @JsonNames("customerIdentity", "consumerIdentity") val customerIdentity: CustomerIdentityModel, - @SerializedName("visitor") + @SerialName("visitor") val visitor: Identifier?, - @SerializedName("destination") + @SerialName("destination") val destination: Identifier?, - @SerializedName("eventType") + @SerialName("eventType") val eventType: EventType, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PolyAction.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PolyAction.kt index 2092819b..60a9d3da 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PolyAction.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/PolyAction.kt @@ -15,17 +15,21 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal sealed class PolyAction { + @Serializable + @SerialName("REPLY_BUTTON") data class ReplyButton( - @SerializedName("text") + @SerialName("text") val text: String, - @SerializedName("postback") + @SerialName("postback") val postback: String?, - @SerializedName("icon") + @SerialName("icon") val media: MediaModel?, - @SerializedName("description") + @SerialName("description") val description: String? ) : PolyAction() } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Postback.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Postback.kt index a933d88e..1a5fca61 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Postback.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Postback.kt @@ -15,12 +15,14 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class Postback( - @SerializedName("eventType") + @SerialName("eventType") val eventType: EventType, - @SerializedName("data") + @SerialName("data") val data: Data, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ProactiveActionInfo.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ProactiveActionInfo.kt index 4216fdd0..f1e8dae8 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ProactiveActionInfo.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ProactiveActionInfo.kt @@ -15,17 +15,21 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.analytics.ActionMetadataInternal +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID -internal data class ProactiveActionInfo constructor( - @SerializedName("actionId") +@Serializable +internal data class ProactiveActionInfo( + @SerialName("actionId") + @Contextual val actionId: UUID, - @SerializedName("actionName") + @SerialName("actionName") val actionName: String, - @SerializedName("actionType") + @SerialName("actionType") val actionType: String, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ReceivedThreadData.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ReceivedThreadData.kt index 9b21f9cd..c08b4778 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ReceivedThreadData.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ReceivedThreadData.kt @@ -15,30 +15,36 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.ChatThreadInternal import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.thread.ChatThreadState.Received +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Date import java.util.UUID +@Serializable internal data class ReceivedThreadData( - @SerializedName("id") + @SerialName("id") internal val id: String, - @SerializedName("idOnExternalPlatform") + @SerialName("idOnExternalPlatform") + @Contextual val idOnExternalPlatform: UUID, - @SerializedName("channelId") + @SerialName("channelId") val channelId: String, - @SerializedName("threadName") + @SerialName("threadName") val threadName: String, - @SerializedName("createdAt") - val createdAt: Date, - @SerializedName("updatedAt") - val updatedAt: Date, - @SerializedName("canAddMoreMessages") + @SerialName("createdAt") + @Contextual + val createdAt: Date?, + @SerialName("updatedAt") + @Contextual + val updatedAt: Date?, + @SerialName("canAddMoreMessages") val canAddMoreMessages: Boolean, - @SerializedName("thread") - val thread: Thread, + @SerialName("thread") + val thread: Thread? = null, ) { fun toChatThread() = ChatThreadInternal( diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/RecoverThreadData.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/RecoverThreadData.kt index 102e6d62..2e16a8f6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/RecoverThreadData.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/RecoverThreadData.kt @@ -15,22 +15,34 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.network.RecoverThreadData.ThreadSpecification.EmptySpecification import com.nice.cxonechat.internal.model.network.RecoverThreadData.ThreadSpecification.ThreadIdSpecification +import kotlinx.serialization.Contextual +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator import java.util.UUID +@Serializable internal data class RecoverThreadData( - @SerializedName("thread") + @SerialName("thread") val thread: ThreadSpecification, ) { + @OptIn(ExperimentalSerializationApi::class) + @Serializable + @JsonClassDiscriminator("client_type") // Temporary solution to avoid possible conflict with the real type internal sealed interface ThreadSpecification { + @Serializable + @SerialName("thread_id") // Not required, but it hides internal class name data class ThreadIdSpecification( - @SerializedName("idOnExternalPlatform") + @SerialName("idOnExternalPlatform") + @Contextual val idOnExternalPlatform: UUID, ) : ThreadSpecification + @Serializable data object EmptySpecification : ThreadSpecification } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Referrer.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Referrer.kt index f5caef54..a5e87f2f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Referrer.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/Referrer.kt @@ -15,9 +15,11 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -internal data class Referrer constructor( - @SerializedName("url") +@Serializable +internal data class Referrer( + @SerialName("url") val url: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ThreadEventData.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ThreadEventData.kt index 59f7c214..0eeb00b1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ThreadEventData.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/ThreadEventData.kt @@ -15,13 +15,15 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.Thread +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Event data to be sent for any thread event (archive, recover, etc.). */ +@Serializable internal data class ThreadEventData( - @SerializedName("thread") + @SerialName("thread") val thread: Thread, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/TimeSpentOnPageModel.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/TimeSpentOnPageModel.kt index 5ed9e3f2..3a3fcb00 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/TimeSpentOnPageModel.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/TimeSpentOnPageModel.kt @@ -15,21 +15,23 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Data to be sent on a page view visitor event. */ +@Serializable internal data class TimeSpentOnPageModel( /** A title for the page that was viewed. */ - @SerializedName("title") + @SerialName("title") val title: String, /** The unique URL or URI for the page that was viewed. Doesn't need to be a valid URL. */ - @SerializedName("url") + @SerialName("url") val url: String, // This can be any identifier for the page; doesn't need to be URL /** Time spent on the page. */ - @SerializedName("timeSpentOnPage") + @SerialName("timeSpentOnPage") val timeSpentOnPage: Long, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UTM.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UTM.kt index d16b5c1c..522d7d55 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UTM.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UTM.kt @@ -15,17 +15,19 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -internal data class UTM constructor( - @SerializedName("source") +@Serializable +internal data class UTM( + @SerialName("source") val source: String, - @SerializedName("medium") + @SerialName("medium") val medium: String, - @SerializedName("campaign") + @SerialName("campaign") val campaign: String, - @SerializedName("term") + @SerialName("term") val term: String, - @SerializedName("content") + @SerialName("content") val content: String, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UserStatistics.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UserStatistics.kt index af83b8c6..c300b9aa 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UserStatistics.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/UserStatistics.kt @@ -15,16 +15,21 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.MessageMetadataInternal import com.nice.cxonechat.message.MessageMetadata +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Date +@Serializable internal data class UserStatistics( - @SerializedName("seenAt") + @SerialName("seenAt") + @Contextual val seenAt: Date?, - @SerializedName("readAt") + @SerialName("readAt") + @Contextual val readAt: Date?, ) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/VisitorEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/VisitorEvent.kt index 07ba0cdf..5d8e86b1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/VisitorEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/VisitorEvent.kt @@ -15,19 +15,25 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.VisitorEventType import com.nice.cxonechat.util.UUIDProvider +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement import java.util.Date import java.util.UUID +@Serializable internal data class VisitorEvent( - @SerializedName("type") + @SerialName("type") val type: VisitorEventType, - @SerializedName("id") + @SerialName("id") + @Contextual val id: UUID = UUIDProvider.next(), - @SerializedName("createdAtWithMilliseconds") + @SerialName("createdAtWithMilliseconds") + @Contextual val createdAt: Date = Date(), - @SerializedName("data") - val data: Any? = null, + @SerialName("data") + val data: JsonElement? = null, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/WrappedText.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/WrappedText.kt index 8798c797..8e1607ee 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/WrappedText.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/WrappedText.kt @@ -15,9 +15,11 @@ package com.nice.cxonechat.internal.model.network -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class WrappedText( - @SerializedName("content") + @SerialName("content") val content: String ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/Default.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/Default.kt index 7fdf11c6..6d3ae48f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/Default.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/Default.kt @@ -15,23 +15,37 @@ package com.nice.cxonechat.internal.serializer -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonParseException -import com.google.gson.JsonSyntaxException -import com.google.gson.TypeAdapter -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonToken.NULL -import com.google.gson.stream.JsonToken.NUMBER -import com.google.gson.stream.JsonWriter +import com.nice.cxonechat.core.BuildConfig import com.nice.cxonechat.internal.model.CustomFieldPolyType +import com.nice.cxonechat.internal.model.ErrorModel +import com.nice.cxonechat.internal.model.network.EventMessageReadByAgent import com.nice.cxonechat.internal.model.network.MessagePolyContent import com.nice.cxonechat.internal.model.network.PolyAction +import com.nice.cxonechat.internal.serializer.Default.DateAsNumberSerializer +import com.nice.cxonechat.internal.serializer.Default.DateSerializer +import com.nice.cxonechat.internal.serializer.Default.UUIDSerializer import com.nice.cxonechat.util.DateTime import com.nice.cxonechat.util.IsoDate import com.nice.cxonechat.util.timestampToDate import com.nice.cxonechat.util.toTimestamp -import java.io.IOException +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.ClassDiscriminatorMode.POLYMORPHIC +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -40,131 +54,133 @@ import kotlin.math.roundToLong internal object Default { - private val messageContentAdapter = RuntimeTypeAdapterFactory.of(MessagePolyContent::class.java, "type") - .registerSubtype(MessagePolyContent.Text::class.java, "TEXT") - .registerSubtype(MessagePolyContent.QuickReplies::class.java, "QUICK_REPLIES") - .registerSubtype(MessagePolyContent.ListPicker::class.java, "LIST_PICKER") - .registerSubtype(MessagePolyContent.RichLink::class.java, "RICH_LINK") - .registerDefault(MessagePolyContent.Noop) - private val customFieldTypeAdapter = RuntimeTypeAdapterFactory.of(CustomFieldPolyType::class.java, "type") - .registerSubtype(CustomFieldPolyType.Text::class.java, "text") - .registerSubtype(CustomFieldPolyType.Email::class.java, "email") - .registerSubtype(CustomFieldPolyType.Selector::class.java, "list") - .registerSubtype(CustomFieldPolyType.Hierarchy::class.java, "tree") - .registerDefault(CustomFieldPolyType.Noop) - private val actionAdapter = RuntimeTypeAdapterFactory.of(PolyAction::class.java, "type") - .registerSubtype(PolyAction.ReplyButton::class.java, "REPLY_BUTTON") - - val serializer: Gson = GsonBuilder() - .registerTypeAdapterFactory(messageContentAdapter) - .registerTypeAdapterFactory(customFieldTypeAdapter) - .registerTypeAdapterFactory(actionAdapter) - .registerTypeAdapter(UUID::class.java, LenientUUIDTypeAdapter()) - .registerTypeAdapter(Date::class.java, DateTypeAdapter()) - .registerTypeAdapter(DateTime::class.java, DateTimeTypeAdapter()) - .registerTypeAdapter(IsoDate::class.java, IsoDateTypeAdapter()) - .create() - - private class DateTypeAdapter : TypeAdapter() { - - override fun write(out: JsonWriter, value: Date?) { - if (value == null) { - out.nullValue() - return - } - out.value(value.toTimestamp()) + private val webSocketModule = SerializersModule { + polymorphic(Any::class) { + subclass(ErrorModel::class) + subclass(EventMessageReadByAgent::class) } + } - override fun read(reader: JsonReader): Date? { - return when (reader.peek()) { - NULL -> { - reader.nextNull() - null - } - NUMBER -> { - var time = reader.nextDouble() - if (time < epochLimitSeconds) time *= 1000 - Date(time.roundToLong()) - } - else -> reader.nextString().timestampToDate() - } + private val messageContentModule = SerializersModule { + polymorphic(MessagePolyContent::class) { + subclass(MessagePolyContent.Text::class) + subclass(MessagePolyContent.QuickReplies::class) + subclass(MessagePolyContent.ListPicker::class) + subclass(MessagePolyContent.RichLink::class) + defaultDeserializer { MessagePolyContent.Noop.serializer() } } + } - companion object { - // this will make the program malfunction on Sat Nov 20 2286 17:46:40 UTC (: - private const val epochLimitSeconds = 10_000_000_000L + private val customFieldTypeModule = SerializersModule { + polymorphic(CustomFieldPolyType::class) { + subclass(CustomFieldPolyType.Text::class) + subclass(CustomFieldPolyType.Email::class) + subclass(CustomFieldPolyType.Selector::class) + subclass(CustomFieldPolyType.Hierarchy::class) + defaultDeserializer { CustomFieldPolyType.Noop.serializer() } + } + } + private val actionModule = SerializersModule { + polymorphic(PolyAction::class) { + subclass(PolyAction.ReplyButton::class) } } - private class DateTimeTypeAdapter : TypeAdapter() { + private val contextualModule = SerializersModule { + contextual(DateSerializer) + contextual(DateTimeSerializer) + contextual(IsoDateSerializer) + contextual(UUIDSerializer as KSerializer) + } - private val fallback = DateTypeAdapter() + @OptIn(ExperimentalSerializationApi::class) + val serializer: Json = Json { + serializersModule = webSocketModule + + messageContentModule + + customFieldTypeModule + + actionModule + + contextualModule + encodeDefaults = true // We are prefilling some constant values for serialization + ignoreUnknownKeys = true // We are ignoring unused properties + isLenient = false // Default is false + coerceInputValues = true + explicitNulls = false // Backend omits null values + prettyPrint = BuildConfig.DEBUG + classDiscriminatorMode = POLYMORPHIC // Mostly implicit + } - override fun write(out: JsonWriter, value: DateTime?) { - if (value == null) { - out.nullValue() - return - } - out.value(value.toTimestamp()) - } + internal object DateSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DateSerializer", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Date) = encoder.encodeString(value.toTimestamp()) + override fun deserialize(decoder: Decoder): Date = decoder.decodeString().timestampToDate() + } - override fun read(reader: JsonReader): DateTime? = fallback.read(reader)?.let(::DateTime) + internal object DateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DateTimeSerializer", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: DateTime) = encoder.encodeString(value.toTimestamp()) + override fun deserialize(decoder: Decoder): DateTime = decoder.decodeSerializableValue(DateSerializer).let(::DateTime) } - private class IsoDateTypeAdapter : TypeAdapter() { - override fun write(out: JsonWriter?, value: IsoDate?) { - if (value == null) { - out?.nullValue() - } else { - out?.value(dateFormatter.format(value.date)) - } + internal object DateAsNumberSerializer : KSerializer { + // this will make the program malfunction on Sat Nov 20 2286 17:46:40 UTC (: + private const val epochLimitSeconds = 10_000_000_000L + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DateAsNumberSerializer", PrimitiveKind.LONG) + override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time) + override fun deserialize(decoder: Decoder): Date { + var time = decoder.decodeLong().toDouble() + if (time < epochLimitSeconds) time *= 1000 + return Date(time.roundToLong()) } + } - override fun read(reader: JsonReader?): IsoDate? { - return if (reader?.peek() == null) { - return null - } else { - with(reader.nextString()) { - dateFormatter.parse(this) ?: throw JsonParseException("Unable to parse date:$this") - }.let(::IsoDate) - } + internal object IsoDateSerializer : KSerializer { + val dateFormatter: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("IsoDateSerializer", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: IsoDate) = encoder.encodeString(dateFormatter.format(value)) + override fun deserialize(decoder: Decoder): IsoDate { + val date = dateFormatter.parse(decoder.decodeString()) ?: throw IsoDateSerializationException() + return IsoDate( + date + ) } - companion object { - val dateFormatter by lazy { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) - } - } + private class IsoDateSerializationException : SerializationException("Invalid date format") } - /** - * Modified version of default [type adapter][com.google.gson.internal.bind.TypeAdapters.UUID] - * which deserializes empty strings as null values. - */ - private class LenientUUIDTypeAdapter : TypeAdapter() { - @Throws(IOException::class) - @Suppress( - "ReturnCount" // Suppressed for readability - ) - override fun read(reader: JsonReader): UUID? { - if (reader.peek() == NULL) { - reader.nextNull() - return null - } - val value = reader.nextString().ifEmpty { - // Due to possible error on backend, the client can receive an empty string instead of null - null - } ?: return null - try { - return UUID.fromString(value) - } catch (e: IllegalArgumentException) { - throw JsonSyntaxException("Failed parsing '" + value + "' as UUID; at path " + reader.previousPath, e) - } + internal object UUIDSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUIDSerializer", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: UUID?) { + value.toString().apply(encoder::encodeString) } - @Throws(IOException::class) - override fun write(writer: JsonWriter, value: UUID?) { - writer.value(value?.toString()) + override fun deserialize(decoder: Decoder): UUID? { + require(decoder is JsonDecoder) + val element = decoder.decodeJsonElement() + + val out = if (element is JsonPrimitive && element.isString) { + val content = element.content + if (content.isNotEmpty()) { + runCatching { UUID.fromString(content) }.getOrNull() + } else { + null + } + } else { + null + } + return out } } } + +internal typealias DateAsString = + @Serializable(DateSerializer::class) + Date + +internal typealias DateAsNumber = + @Serializable(DateAsNumberSerializer::class) + Date + +internal typealias SerializedUUID = + @Serializable(UUIDSerializer::class) + UUID diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/RuntimeTypeAdapterFactory.java b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/RuntimeTypeAdapterFactory.java deleted file mode 100644 index 52f766b7..00000000 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/RuntimeTypeAdapterFactory.java +++ /dev/null @@ -1,322 +0,0 @@ -/* - * Copyright (C) 2011 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.nice.cxonechat.internal.serializer; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.google.gson.JsonPrimitive; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - *

Local Customization

- * - *

- * Method {@code registerDefault} has been added to avoid hard errors during deserialization. - * Deserialization still fails if the registered value is null or not defined. - *

- * - *

Description

- * - * Adapts values whose runtime type may differ from their declaration type. This - * is necessary when a field's type is not the same type that GSON should create - * when deserializing that field. For example, consider these types: - *
   {@code
- *   abstract class Shape {
- *     int x;
- *     int y;
- *   }
- *   class Circle extends Shape {
- *     int radius;
- *   }
- *   class Rectangle extends Shape {
- *     int width;
- *     int height;
- *   }
- *   class Diamond extends Shape {
- *     int width;
- *     int height;
- *   }
- *   class Drawing {
- *     Shape bottomShape;
- *     Shape topShape;
- *   }
- * }
- *

Without additional type information, the serialized JSON is ambiguous. Is - * the bottom shape in this drawing a rectangle or a diamond?

   {@code
- *   {
- *     "bottomShape": {
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * This class addresses this problem by adding type information to the - * serialized JSON and honoring that type information when the JSON is - * deserialized:
   {@code
- *   {
- *     "bottomShape": {
- *       "type": "Diamond",
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "type": "Circle",
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * Both the type field name ({@code "type"}) and the type labels ({@code - * "Rectangle"}) are configurable. - * - *

Registering Types

- * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field - * name to the {@link #of} factory method. If you don't supply an explicit type - * field name, {@code "type"} will be used.
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory
- *       = RuntimeTypeAdapterFactory.of(Shape.class, "type");
- * }
- * Next register all of your subtypes. Every subtype must be explicitly - * registered. This protects your application from injection attacks. If you - * don't supply an explicit type label, the type's simple name will be used. - *
   {@code
- *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
- *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
- *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
- * }
- * Finally, register the type adapter factory in your application's GSON builder: - *
   {@code
- *   Gson gson = new GsonBuilder()
- *       .registerTypeAdapterFactory(shapeAdapterFactory)
- *       .create();
- * }
- * Like {@code GsonBuilder}, this API supports chaining:
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
- *       .registerSubtype(Rectangle.class)
- *       .registerSubtype(Circle.class)
- *       .registerSubtype(Diamond.class);
- * }
- * - *

Serialization and deserialization

- * In order to serialize and deserialize a polymorphic object, - * you must specify the base type explicitly. - *
   {@code
- *   Diamond diamond = new Diamond();
- *   String json = gson.toJson(diamond, Shape.class);
- * }
- * And then: - *
   {@code
- *   Shape shape = gson.fromJson(json, Shape.class);
- * }
- */ -final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { - private final Class baseType; - private final String typeFieldName; - private final Map> labelToSubtype = new LinkedHashMap<>(); - private final Map, String> subtypeToLabel = new LinkedHashMap<>(); - private final boolean maintainType; - private boolean recognizeSubtypes; - private T defaultValue = null; - - private RuntimeTypeAdapterFactory( - Class baseType, String typeFieldName, boolean maintainType) { - if (typeFieldName == null || baseType == null) { - throw new NullPointerException(); - } - this.baseType = baseType; - this.typeFieldName = typeFieldName; - this.maintainType = maintainType; - } - - /** - * Creates a new runtime type adapter using for {@code baseType} using {@code - * typeFieldName} as the type field name. Type field names are case sensitive. - * - * @param maintainType true if the type field should be included in deserialized objects - */ - public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName, boolean maintainType) { - return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType); - } - - /** - * Creates a new runtime type adapter using for {@code baseType} using {@code - * typeFieldName} as the type field name. Type field names are case sensitive. - */ - public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { - return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false); - } - - /** - * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as - * the type field name. - */ - public static RuntimeTypeAdapterFactory of(Class baseType) { - return new RuntimeTypeAdapterFactory<>(baseType, "type", false); - } - - /** - * Ensures that this factory will handle not just the given {@code baseType}, but any subtype - * of that type. - */ - public RuntimeTypeAdapterFactory recognizeSubtypes() { - this.recognizeSubtypes = true; - return this; - } - - /** - * Registers {@code type} identified by {@code label}. Labels are case - * sensitive. - * - * @throws IllegalArgumentException if either {@code type} or {@code label} - * have already been registered on this type adapter. - */ - public RuntimeTypeAdapterFactory registerSubtype(Class type, String label) { - if (type == null || label == null) { - throw new NullPointerException(); - } - if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { - throw new IllegalArgumentException("types and labels must be unique"); - } - labelToSubtype.put(label, type); - subtypeToLabel.put(type, label); - return this; - } - - /** - * Registers {@code type} identified by its {@link Class#getSimpleName simple - * name}. Labels are case sensitive. - * - * @throws IllegalArgumentException if either {@code type} or its simple name - * have already been registered on this type adapter. - */ - public RuntimeTypeAdapterFactory registerSubtype(Class type) { - return registerSubtype(type, type.getSimpleName()); - } - - /** - * Registers {@code value} to be default when no other deserializer can handle this type. - */ - public RuntimeTypeAdapterFactory registerDefault(@NotNull T value) { - defaultValue = value; - return this; - } - - @Override - public TypeAdapter create(Gson gson, TypeToken type) { - if (type == null) { - return null; - } - Class rawType = type.getRawType(); - boolean handle = - recognizeSubtypes ? baseType.isAssignableFrom(rawType) : baseType.equals(rawType); - if (!handle) { - return null; - } - - final TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); - final Map> labelToDelegate = new LinkedHashMap<>(); - final Map, TypeAdapter> subtypeToDelegate = new LinkedHashMap<>(); - for (Map.Entry> entry : labelToSubtype.entrySet()) { - TypeAdapter delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); - labelToDelegate.put(entry.getKey(), delegate); - subtypeToDelegate.put(entry.getValue(), delegate); - } - - return new TypeAdapter() { - @Override - public R read(JsonReader in) throws IOException { - JsonElement jsonElement = jsonElementAdapter.read(in); - JsonElement labelJsonElement; - if (maintainType) { - labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); - } else { - labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); - } - - if (labelJsonElement == null) { - if (defaultValue != null) { - return (R) defaultValue; - } - throw new JsonParseException("cannot deserialize " + baseType - + " because it does not define a field named " + typeFieldName); - } - String label = labelJsonElement.getAsString(); - @SuppressWarnings("unchecked") // registration requires that subtype extends T - TypeAdapter delegate = (TypeAdapter) labelToDelegate.get(label); - if (delegate == null) { - if (defaultValue != null) { - return (R) defaultValue; - } - throw new JsonParseException("cannot deserialize " + baseType + " subtype named " - + label + "; did you forget to register a subtype?"); - } - return delegate.fromJsonTree(jsonElement); - } - - @Override - public void write(JsonWriter out, R value) throws IOException { - Class srcType = value.getClass(); - String label = subtypeToLabel.get(srcType); - @SuppressWarnings("unchecked") // registration requires that subtype extends T - TypeAdapter delegate = (TypeAdapter) subtypeToDelegate.get(srcType); - if (delegate == null) { - throw new JsonParseException("cannot serialize " + srcType.getName() - + "; did you forget to register a subtype?"); - } - JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); - - if (maintainType) { - jsonElementAdapter.write(out, jsonObject); - return; - } - - JsonObject clone = new JsonObject(); - - if (jsonObject.has(typeFieldName)) { - throw new JsonParseException("cannot serialize " + srcType.getName() - + " because it already defines a field named " + typeFieldName); - } - clone.add(typeFieldName, new JsonPrimitive(label)); - - for (Map.Entry e : jsonObject.entrySet()) { - clone.add(e.getKey(), e.getValue()); - } - jsonElementAdapter.write(out, clone); - } - }.nullSafe(); - } -} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ErrorCallback.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ErrorCallback.kt index 47a23f51..34906d29 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ErrorCallback.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ErrorCallback.kt @@ -33,7 +33,7 @@ internal abstract class ErrorCallback( ) : WebSocketListener() { override fun onMessage(webSocket: WebSocket, text: String) { - val errorMessage: ErrorModel? = serializer.runCatching { fromJson(text, ErrorModel::class.java) }.getOrNull() + val errorMessage: ErrorModel? = serializer.runCatching { decodeFromString(text) }.getOrNull() if (errorMessage?.error?.errorCode == errorType) { onError(webSocket) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventBlueprint.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventBlueprint.kt index 5e037cde..3173c2ac 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventBlueprint.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventBlueprint.kt @@ -15,21 +15,24 @@ package com.nice.cxonechat.internal.socket -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.enums.EventType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable internal data class EventBlueprint( - @SerializedName("eventType") + @SerialName("eventType") val type: EventType?, - @SerializedName("postback") + @SerialName("postback") val postback: Postback? ) { val anyType get() = type ?: postback?.type + @Serializable data class Postback( - @SerializedName("eventType") + @SerialName("eventType") val type: EventType? ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventCallback.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventCallback.kt index a665b994..ed1d1313 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventCallback.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventCallback.kt @@ -18,8 +18,10 @@ package com.nice.cxonechat.internal.socket import com.nice.cxonechat.Cancellable import com.nice.cxonechat.enums.ErrorType import com.nice.cxonechat.enums.EventType -import com.nice.cxonechat.internal.serializer.Default.serializer +import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.internal.socket.ErrorCallback.Companion.addErrorCallback +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.serializer import okhttp3.WebSocket import okhttp3.WebSocketListener import java.util.UUID @@ -28,7 +30,7 @@ internal abstract class EventCallback( private val type: EventType, private val eventType: Class, ) : WebSocketListener() { - + private val serializer = Default.serializer.serializersModule.serializer(eventType) as DeserializationStrategy interface ReceivedEvent { val type: EventType } @@ -38,13 +40,11 @@ internal abstract class EventCallback( } override fun onMessage(webSocket: WebSocket, text: String) { - val blueprint: EventBlueprint? = serializer.runCatching { - fromJson(text, EventBlueprint::class.java) + val blueprint: EventBlueprint? = Default.serializer.runCatching { + decodeFromString(text) }.getOrNull() if (blueprint?.anyType === type) { - serializer.fromJson(text, eventType)?.let { - onEvent(webSocket, it) - } + onEvent(webSocket, Default.serializer.decodeFromString(serializer, text)) } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventLogger.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventLogger.kt index b9e10eec..6909228f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventLogger.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/EventLogger.kt @@ -15,11 +15,15 @@ package com.nice.cxonechat.internal.socket +import com.nice.cxonechat.core.BuildConfig +import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.debug import com.nice.cxonechat.log.scope import com.nice.cxonechat.log.verbose +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener @@ -36,9 +40,20 @@ internal class EventLogger( webSocket: WebSocket, text: String, ) = scope("onMessage") { - verbose(text) + val jsonText: String = when { + BuildConfig.DEBUG -> prettyPrint(text) + else -> text + } + verbose(jsonText) } + private fun prettyPrint(text: String) = runCatching { + Default.serializer.encodeToString( + serializer = JsonObject.serializer(), + value = Default.serializer.parseToJsonElement(text).jsonObject + ) + }.getOrDefault(text) + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = scope("onFailure") { debug("Response: $response", t) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketExt.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketExt.kt index 543c34c8..95a2e3a2 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketExt.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketExt.kt @@ -16,6 +16,7 @@ package com.nice.cxonechat.internal.socket import com.nice.cxonechat.internal.serializer.Default.serializer +import kotlinx.serialization.serializer import okhttp3.WebSocket /** @@ -23,7 +24,8 @@ import okhttp3.WebSocket * [callback] will be invoked if the [send] has reported that message has been enqueued. */ internal fun WebSocket.send(model: Any, callback: (() -> Unit)? = null) { - val text = serializer.toJson(model) + val kser = serializer.serializersModule.serializer(model::class.java) + val text = serializer.encodeToString(kser, model) if (send(text = text)) { callback?.invoke() } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageAuthor.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageAuthor.kt index 75b669d2..c4a5f051 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageAuthor.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageAuthor.kt @@ -54,6 +54,9 @@ abstract class MessageAuthor { */ abstract val imageUrl: String? + /** The optional nickname of the author. */ + open val nickname: String? = null + /** * Merges [firstName] and [lastName] in this order, separated by a space. * If both values are empty, then returns an empty string. diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/PreferencesValueStorage.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/PreferencesValueStorage.kt index d36fc430..2b522c73 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/PreferencesValueStorage.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/PreferencesValueStorage.kt @@ -21,6 +21,7 @@ import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.storage.ValueStorage.VisitDetails +import kotlinx.serialization.encodeToString import java.util.Date import java.util.UUID @@ -60,10 +61,10 @@ internal class PreferencesValueStorage(private val sharedPreferences: SharedPref override var visitDetails: VisitDetails? get() = sharedPreferences.getString(PREF_VISIT_DETAILS, null)?.let { - Default.serializer.fromJson(it, VisitDetails::class.java) + Default.serializer.decodeFromString(it) } set(value) = sharedPreferences.edit { - putString(PREF_VISIT_DETAILS, value?.let { Default.serializer.toJson(it) }) + putString(PREF_VISIT_DETAILS, value?.let { Default.serializer.encodeToString(it) }) } override val visitId: UUID diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/ValueStorage.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/ValueStorage.kt index f49193c7..93d83ea1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/ValueStorage.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/storage/ValueStorage.kt @@ -15,7 +15,10 @@ package com.nice.cxonechat.storage -import com.google.gson.annotations.SerializedName +import com.nice.cxonechat.internal.serializer.DateAsNumber +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.Date import java.util.UUID @@ -24,11 +27,13 @@ import java.util.UUID */ @Suppress("ComplexInterface") internal interface ValueStorage { + @Serializable data class VisitDetails( - @SerializedName("visitId") + @SerialName("visitId") + @Contextual val visitId: UUID = UUID.randomUUID(), - @SerializedName("validUntil") - val validUntil: Date = Date(Date().time + 30 * 60 * 1000) + @SerialName("validUntil") + val validUntil: DateAsNumber = Date(Date().time + 30 * 60 * 1000) ) /** diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/Agent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/Agent.kt index f6431cb2..2c5d2759 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/Agent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/Agent.kt @@ -27,10 +27,16 @@ abstract class Agent { abstract val id: Int /** The id of the agent in the inContact (CXone) system. */ - abstract val inContactId: UUID? // todo find out why is this nullable + @Deprecated( + message = "inContactId is internal field and should not be used. It is now always null.", + ) + abstract val inContactId: UUID? /** The email address of the agent. */ - abstract val emailAddress: String? // todo find out why is this nullable + @Deprecated( + message = "emailAddress is internal field and should not be used. It is now always null.", + ) + abstract val emailAddress: String? /** The first name of the agent. */ abstract val firstName: String @@ -38,8 +44,8 @@ abstract class Agent { /** The surname of the agent. */ abstract val lastName: String - /** The nickname of the agent. */ - abstract val nickname: String? // todo find out why is this nullable or necessary + /** The optional nickname of the agent. */ + abstract val nickname: String? /** Whether the agent is a bot. */ abstract val isBotUser: Boolean diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateTime.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateTime.kt index 6704cb3e..bf1466a1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateTime.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateTime.kt @@ -15,8 +15,11 @@ package com.nice.cxonechat.util +import com.nice.cxonechat.internal.serializer.Default.DateTimeSerializer +import kotlinx.serialization.Serializable import java.util.Date +@Serializable(with = DateTimeSerializer::class) internal data class DateTime( val date: Date, ) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/IsoDate.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/IsoDate.kt index 8be81c32..eb781b62 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/IsoDate.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/IsoDate.kt @@ -15,8 +15,11 @@ package com.nice.cxonechat.util +import com.nice.cxonechat.internal.serializer.Default.IsoDateSerializer +import kotlinx.serialization.Serializable import java.util.Date +@Serializable(with = IsoDateSerializer::class) internal data class IsoDate( val date: Date ) diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt index fb858c89..21dbfd6f 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt @@ -30,6 +30,7 @@ import com.nice.cxonechat.storage.ValueStorage import com.nice.cxonechat.tool.ChatEntrailsMock import com.nice.cxonechat.tool.MockServer import com.nice.cxonechat.tool.awaitResult +import com.nice.cxonechat.util.plus import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -41,8 +42,10 @@ import org.junit.Before import retrofit2.Call import retrofit2.Callback import retrofit2.Response +import java.util.Date import java.util.UUID import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds internal abstract class AbstractChatTestSubstrate { @@ -109,7 +112,7 @@ internal abstract class AbstractChatTestSubstrate { every { destinationId } returns UUID.fromString(TestUUID) every { welcomeMessage } returns "welcome" every { authToken } returns "token" - every { authTokenExpDate } returns null + every { authTokenExpDate } returns Date().plus(1.days.inWholeMilliseconds) every { deviceToken } returns null } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChannelsConfigurationTests.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChannelsConfigurationTests.kt index 5da31ee4..7d0bf019 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChannelsConfigurationTests.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChannelsConfigurationTests.kt @@ -15,17 +15,15 @@ package com.nice.cxonechat -import com.google.gson.Gson -import com.google.gson.JsonParser import com.nice.cxonechat.internal.model.AvailabilityStatus.Offline import com.nice.cxonechat.internal.model.AvailabilityStatus.Online import com.nice.cxonechat.internal.model.ChannelConfiguration -import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.state.Configuration.Feature.LiveChatLogoHidden import com.nice.cxonechat.state.Configuration.Feature.ProactiveChatEnabled import com.nice.cxonechat.state.Configuration.Feature.RecoverLiveChatDoesNotFail import com.nice.cxonechat.state.FieldDefinition.Hierarchy import junit.framework.TestCase.assertTrue +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -35,17 +33,20 @@ internal class ChannelsConfigurationTests { requireNotNull(ResourceHelper.loadString("channelconfiguration.json")) } + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + private fun configuration( isLiveChat: Boolean = false, isOnline: Boolean = true, - ) = JsonParser.parseString(channelConfigurationData).asJsonObject.apply { - addProperty("isLiveChat", isLiveChat) - with(getAsJsonObject("availability")) { - addProperty("status", if (isOnline) "online" else "offline") - } + ) = json.decodeFromString(channelConfigurationData).let { + it.copy( + isLiveChat = isLiveChat, + availability = it.availability.copy(status = if (isOnline) Online else Offline) + ) } - .let(Gson()::toJson) - .let { Default.serializer.fromJson(it, ChannelConfiguration::class.java) } @Test fun testLiveChatRelatedParsing() { diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt index 8c544af0..195bd78b 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt @@ -15,7 +15,7 @@ package com.nice.cxonechat -import com.google.gson.GsonBuilder +import com.google.gson.Strictness import com.nice.cxonechat.ChatEventHandlerActions.conversion import com.nice.cxonechat.ChatEventHandlerActions.pageView import com.nice.cxonechat.ChatEventHandlerActions.proactiveActionClick @@ -33,14 +33,18 @@ import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionDisplayed import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionFailed import com.nice.cxonechat.enums.VisitorEventType.ProactiveActionSuccess import com.nice.cxonechat.event.AnalyticsEvent +import com.nice.cxonechat.event.AnalyticsEvent.Data +import com.nice.cxonechat.event.AnalyticsEvent.Data.ValueMapData import com.nice.cxonechat.event.AnalyticsEvent.Destination import com.nice.cxonechat.internal.ChatEventHandlerImpl import com.nice.cxonechat.internal.ChatWithParameters import com.nice.cxonechat.internal.RemoteServiceBuilder import com.nice.cxonechat.internal.model.network.PageViewData +import com.nice.cxonechat.internal.model.network.ProactiveActionInfo import com.nice.cxonechat.state.Connection import com.nice.cxonechat.state.Environment import com.nice.cxonechat.storage.ValueStorage +import com.nice.cxonechat.tool.Gson import com.nice.cxonechat.tool.MockInterceptor import com.nice.cxonechat.tool.awaitResult import io.mockk.every @@ -59,9 +63,9 @@ import com.nice.cxonechat.internal.model.network.Conversion as ConversionModel internal class ChatEventHandlerActionsTest { private val gson by lazy { - GsonBuilder() + Gson.newBuilder() .registerTypeAdapter(Date::class.java, GsonUTCDateAdapter()) - .setLenient() + .setStrictness(Strictness.LENIENT) .create() } private val visitorId = UUID.randomUUID() @@ -151,14 +155,14 @@ internal class ChatEventHandlerActionsTest { } } - private fun event(type: VisitorEventType, data: Any = mapOf()): AnalyticsEvent { + private fun event(type: VisitorEventType, data: Data = ValueMapData(mapOf())): AnalyticsEvent { return AnalyticsEvent( eventId, type, visitId, Destination(destinationId), now, - gson.toJson(data).let { gson.fromJson(it, Map::class.java) } + data, ) } @@ -166,7 +170,7 @@ internal class ChatEventHandlerActionsTest { fun conversion() { val expect = event( Conversion, - ConversionModel("cash", 324, now) + Data.ConversionData(ConversionModel("cash", 324, now)) ) verifyEventSent(expect) { done -> events.conversion("cash", 324, now) { done() } @@ -177,7 +181,7 @@ internal class ChatEventHandlerActionsTest { fun pageView() { val expect = event( PageView, - PageViewData("some title", "https://some.url/or/other") + Data.PageViewData(PageViewData("some title", "https://some.url/or/other")) ) verifyEventSent(expect) { done -> events.pageView("some title", "https://some.url/or/other", now) { done() } @@ -186,7 +190,7 @@ internal class ChatEventHandlerActionsTest { @Test fun proactiveActionClick() { - val expect = event(ProactiveActionClicked, actionMetaData) + val expect = event(ProactiveActionClicked, Data.ProactiveActionData(ProactiveActionInfo(actionMetaData))) verifyEventSent(expect) { done -> events.proactiveActionClick(actionMetaData, now) { done() } } @@ -194,7 +198,7 @@ internal class ChatEventHandlerActionsTest { @Test fun proactiveActionDisplay() { - val expect = event(ProactiveActionDisplayed, actionMetaData) + val expect = event(ProactiveActionDisplayed, Data.ProactiveActionData(ProactiveActionInfo(actionMetaData))) verifyEventSent(expect) { done -> events.proactiveActionDisplay(actionMetaData, now) { done() } } @@ -202,7 +206,7 @@ internal class ChatEventHandlerActionsTest { @Test fun proactiveActionFailure() { - val expect = event(ProactiveActionFailed, actionMetaData) + val expect = event(ProactiveActionFailed, Data.ProactiveActionData(ProactiveActionInfo(actionMetaData))) verifyEventSent(expect) { done -> events.proactiveActionFailure(actionMetaData, now) { done() } } @@ -210,7 +214,7 @@ internal class ChatEventHandlerActionsTest { @Test fun proactiveActionSuccess() { - val expect = event(ProactiveActionSuccess, actionMetaData) + val expect = event(ProactiveActionSuccess, Data.ProactiveActionData(ProactiveActionInfo(actionMetaData))) verifyEventSent(expect) { done -> events.proactiveActionSuccess(actionMetaData, now) { done() } } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerTest.kt index be1ea4dc..291b603b 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatEventHandlerTest.kt @@ -17,7 +17,9 @@ package com.nice.cxonechat import com.nice.cxonechat.event.ChatEvent import com.nice.cxonechat.server.ServerRequest +import com.nice.cxonechat.server.ServerResponse import io.mockk.every +import kotlinx.serialization.Serializable import org.junit.Test import java.util.Date @@ -28,13 +30,20 @@ internal class ChatEventHandlerTest : AbstractChatTest() { override fun prepare() { super.prepare() events = chat.events() + this serverResponds ServerResponse.ConsumerAuthorized() } // --- @Test fun trigger_sendExpectedMessage() { - assertSendText("""{"field":104}""") { + assertSendText( + """ + { + "field": 104 + } + """.trimIndent() + ) { events.trigger(ChatEvent.Custom { _, _ -> TestValue() }) } } @@ -44,12 +53,17 @@ internal class ChatEventHandlerTest : AbstractChatTest() { every { storage.authTokenExpDate } returns Date() assertSendTexts( ServerRequest.RefreshToken(connection), - """{"field":104}""" + """ + { + "field": 104 + } + """.trimIndent() ) { events.trigger(ChatEvent.Custom { _, _ -> TestValue() }) } } + @Serializable data class TestValue( val field: Int = 104, ) diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatLiveTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatLiveTest.kt index 583578f1..b56f0db0 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatLiveTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatLiveTest.kt @@ -17,7 +17,9 @@ package com.nice.cxonechat import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection.Ready +import com.nice.cxonechat.enums.ContactStatus.Closed import com.nice.cxonechat.enums.ErrorType.RecoveringLivechatFailed +import com.nice.cxonechat.internal.ChatThreadHandlerLiveChat.Companion.BEGIN_CONVERSATION_MESSAGE import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable import com.nice.cxonechat.internal.model.AvailabilityStatus.Offline import com.nice.cxonechat.internal.model.MessageModel @@ -29,6 +31,7 @@ import com.nice.cxonechat.thread.ChatThread import io.kotest.matchers.shouldBe import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -116,6 +119,36 @@ internal class ChatLiveTest : AbstractChatTest() { } } + @Test + fun connect_live_chat_ignores_closed_recovered_thread() { + val messages = arrayOf(makeMessageModel()) + val recovered = makeChatThread().asCopyable().copy( + messages = messages.mapNotNull(MessageModel::toMessage), + contactId = TestContactId + ) + assertSendTexts( + ServerRequest.ReconnectConsumer(connection), + ServerRequest.RecoverLiveChatThread(connection, null), // Connect + ServerRequest.RecoverLiveChatThread(connection, null), // Refresh thread state on first call + ServerRequest.SendMessage(connection, + makeChatThread(TestUUIDValue, ""), + storage = storage, + message = BEGIN_CONVERSATION_MESSAGE + ) + ) { + connect() + assertEquals(ChatStateConnection.Connected, chatStateListener.connection) + val actual = testCallback(::get) { + sendServerMessage( + ServerResponse.LivechatRecovered(thread = recovered, messages = messages, status = Closed) + ) + } + assertNotNull(actual) + assertEquals(Ready, chatStateListener.connection) + assertNotEquals(recovered, actual) + } + } + private fun get(listener: (ChatThread?) -> Unit): Cancellable = chat.threads().threads(listener = { threadList -> listener(threadList.firstOrNull()) }) } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt index 8e18e431..478de880 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt @@ -20,6 +20,7 @@ import com.nice.cxonechat.model.makeChatThread import com.nice.cxonechat.server.ServerRequest import com.nice.cxonechat.thread.ChatThread import io.mockk.every +import kotlinx.serialization.Serializable import org.junit.Test import java.util.Date @@ -39,7 +40,13 @@ internal class ChatThreadEventHandlerTest : AbstractChatTest() { @Test fun trigger_sendsExpectedMessage() { val event = ChatThreadEvent.Custom { TestModel() } - assertSendText("""{"field":10}""") { + assertSendText( + """ + { + "field": 10 + } + """.trimIndent() + ) { events.trigger(event) } } @@ -49,12 +56,17 @@ internal class ChatThreadEventHandlerTest : AbstractChatTest() { every { storage.authTokenExpDate } returns Date() assertSendTexts( ServerRequest.RefreshToken(connection), - """{"field":10}""" + """ + { + "field": 10 + } + """.trimIndent() ) { events.trigger(ChatThreadEvent.Custom { TestModel() }) } } + @Serializable data class TestModel( val field: Int = 10, ) diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadHandlerMessageReadByAgentTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadHandlerMessageReadByAgentTest.kt index 97aa1bfd..7d228be7 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadHandlerMessageReadByAgentTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadHandlerMessageReadByAgentTest.kt @@ -15,8 +15,6 @@ package com.nice.cxonechat -import com.google.gson.JsonObject -import com.google.gson.JsonSerializer import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable import com.nice.cxonechat.internal.model.MessageModel import com.nice.cxonechat.internal.model.network.MessagePolyContent.Noop @@ -29,6 +27,7 @@ import org.junit.Test import java.util.Date import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull internal class ChatThreadHandlerMessageReadByAgentTest : AbstractChatTest() { @@ -61,6 +60,7 @@ internal class ChatThreadHandlerMessageReadByAgentTest : AbstractChatTest() { val actual = testCallback(::get) { sendServerMessage(ServerResponse.MessageReadChanged(message)) } + assertNotNull(actual) assertEquals(expected, actual.asCopyable().copy()) } @@ -74,17 +74,10 @@ internal class ChatThreadHandlerMessageReadByAgentTest : AbstractChatTest() { @Test fun read_event_without_message_is_ignored() { - val serializer = JsonSerializer { _, _, _ -> - JsonObject().apply { - addProperty("type", "noop") - } - } - val pair = Noop::class.java to serializer val actual = testCallback(::get) { sendServerMessage( ServerResponse.MessageReadChanged( message = message.copy(messageContent = Noop), - temporaryTypeAdapters = mapOf(pair) ) ) } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadMessageHandlerAttachmentTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadMessageHandlerAttachmentTest.kt index 5e236e1b..b3d4b6d7 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadMessageHandlerAttachmentTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/ChatThreadMessageHandlerAttachmentTest.kt @@ -139,7 +139,7 @@ internal class ChatThreadMessageHandlerAttachmentTest : AbstractChatTest() { @OptIn(ExperimentalEncodingApi::class) @Test fun send_attachment_notifies_about_failure_in_response() { - val expected = nextString() + val expected = nextString(8) val filename = nextString() val postback = nextString() val bytes = Base64.decode(expected) @@ -179,10 +179,10 @@ internal class ChatThreadMessageHandlerAttachmentTest : AbstractChatTest() { @OptIn(ExperimentalEncodingApi::class) @Test fun send_attachment_notifies_about_failure_in_network_call() { - val expected = nextString() + val expected = nextString(length = 8) val filename = nextString() val postback = nextString() - val bytes = Base64.decode(expected) + val bytes = Base64.UrlSafe.decode(expected) mockAndroidBase64() diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/api/RemoteServiceTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/api/RemoteServiceTest.kt index 2db5cfe7..a424abb7 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/api/RemoteServiceTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/api/RemoteServiceTest.kt @@ -17,7 +17,6 @@ package com.nice.cxonechat.api -import com.google.gson.GsonBuilder import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement @@ -25,14 +24,18 @@ import com.google.gson.JsonParseException import com.google.gson.JsonPrimitive import com.google.gson.JsonSerializationContext import com.google.gson.JsonSerializer +import com.nice.cxonechat.enums.ActionType import com.nice.cxonechat.enums.VisitorEventType.VisitorVisit import com.nice.cxonechat.event.AnalyticsEvent +import com.nice.cxonechat.event.AnalyticsEvent.Data import com.nice.cxonechat.event.AnalyticsEvent.Destination import com.nice.cxonechat.internal.RemoteServiceBuilder import com.nice.cxonechat.internal.model.AttachmentUploadModel import com.nice.cxonechat.internal.model.AvailabilityStatus.Offline import com.nice.cxonechat.internal.model.AvailabilityStatus.Online import com.nice.cxonechat.internal.model.ChannelAvailability +import com.nice.cxonechat.internal.model.network.ProactiveActionInfo +import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.model.makeConnection import com.nice.cxonechat.tool.MockInterceptor import io.kotest.matchers.shouldBe @@ -126,7 +129,7 @@ internal class RemoteServiceTest { kVisitId, Destination(kDestinationId), kNow, - mapOf() + Data.ProactiveActionData(ProactiveActionInfo(UUID.randomUUID(), "name", ActionType.WelcomeMessage.value)) ) client.postEvent( @@ -137,11 +140,6 @@ internal class RemoteServiceTest { assertEquals(1, recorder.requests.count()) val dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'z'" - val gson = GsonBuilder() - .registerTypeAdapter(Date::class.java, GsonUTCDateAdapter()) - .setDateFormat(dateFormat) - .setLenient() - .create() with(recorder.requests.first()) { assertEquals("POST", method) @@ -151,7 +149,7 @@ internal class RemoteServiceTest { ) val actual = body?.asString?.let { - gson.fromJson(it, AnalyticsEvent::class.java) + Default.serializer.decodeFromString(AnalyticsEvent.serializer(), it) } assertEquals(expect, actual) diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt index d9f2c452..5bac7802 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt @@ -12,3 +12,4 @@ * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ + diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadHandlerLiveChatTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadHandlerLiveChatTest.kt index b0ece19e..c925b295 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadHandlerLiveChatTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/ChatThreadHandlerLiveChatTest.kt @@ -45,7 +45,7 @@ internal class ChatThreadHandlerLiveChatTest : AbstractChatTest() { val expected = chatThread.asCopyable().copy(positionInQueue = 10, hasOnlineAgent = true, contactId = TestContactId) val actual = testCallback(::get) { - sendServerMessage(ServerResponse.SetPositionInQueue(position = 10, isAgentAvailable = true)) + sendServerMessage(ServerResponse.SetPositionInQueue(position = 10, isAgentAvailable = true, threadId = chatThread.id)) } assertEquals(expected, actual.asCopyable().copy()) diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/MessageModelTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/MessageModelTest.kt index bb0377d8..0f3d1e42 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/MessageModelTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/MessageModelTest.kt @@ -38,8 +38,6 @@ internal class MessageModelTest { userStatistics = mockk(), authorUser = AgentModel( id = 1, - inContactId = UUID.randomUUID(), - emailAddress = null, firstName = "Agent", surname = "Name", nickname = null, diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/serializer/DefaultTest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/serializer/DefaultTest.kt index a2c5a9d8..3de7faff 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/serializer/DefaultTest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/internal/serializer/DefaultTest.kt @@ -17,8 +17,8 @@ package com.nice.cxonechat.internal.serializer -import com.nice.cxonechat.internal.model.AgentModel import com.nice.cxonechat.model.makeAgent +import com.nice.cxonechat.tool.nextString import com.nice.cxonechat.tool.serialize import org.junit.Test import kotlin.test.assertEquals @@ -30,19 +30,18 @@ internal class DefaultTest { */ @Test fun verify_lenient_UUID_parsing_for_empty_strings() { - val expectedAgent = makeAgent(inContactId = null) + val expectedAgent = makeAgent() val agent = object { val id = expectedAgent.id - val inContactId = "" - val emailAddress: String? = expectedAgent.emailAddress val firstName: String = expectedAgent.firstName val surname: String = expectedAgent.surname val nickname: String? = expectedAgent.nickname val isBotUser: Boolean = expectedAgent.isBotUser val isSurveyUser: Boolean = expectedAgent.isSurveyUser - val imageUrl: String = expectedAgent.imageUrl + val imageUrl: String = nextString() + val publicImageUrl: String = expectedAgent.imageUrl } val serializedAgent = agent.serialize() - assertEquals(expectedAgent, Default.serializer.fromJson(serializedAgent, AgentModel::class.java)) + assertEquals(expectedAgent, Default.serializer.decodeFromString(serializedAgent)) } } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/model/ThreadMetadataLoadedEvent.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/model/ThreadMetadataLoadedEvent.kt index af2a48bf..fba47ebe 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/model/ThreadMetadataLoadedEvent.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/model/ThreadMetadataLoadedEvent.kt @@ -17,15 +17,12 @@ package com.nice.cxonechat.model import com.nice.cxonechat.internal.model.AgentModel import com.nice.cxonechat.tool.nextString -import java.util.UUID import kotlin.random.Random.Default.nextBoolean import kotlin.random.Random.Default.nextInt @Suppress("LongParameterList") internal fun makeAgent( id: Int = nextInt(), - inContactId: UUID? = UUID.randomUUID(), - emailAddress: String? = nextString(), firstName: String = nextString(), surname: String = nextString(), nickname: String? = nextString(), @@ -34,8 +31,6 @@ internal fun makeAgent( imageUrl: String = nextString(), ) = AgentModel( id = id, - inContactId = inContactId, - emailAddress = emailAddress, firstName = firstName, surname = surname, nickname = nickname, diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequest.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequest.kt index a89aef28..01dd23e5 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequest.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequest.kt @@ -65,9 +65,15 @@ import com.nice.cxonechat.state.Connection import com.nice.cxonechat.storage.ValueStorage import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.tool.serialize +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement import java.util.Date import java.util.UUID +@Suppress( + "FunctionNaming", + "ExpressionBodySyntax" +) internal object ServerRequest { fun LoadMore(connection: Connection, thread: ChatThread): String = ActionLoadMoreMessages( @@ -124,9 +130,15 @@ internal object ServerRequest { return ActionReconnectCustomer(connection, TestUUIDValue, token).copy(eventId = TestUUIDValue).serialize().verifyReconnectConsumer() } - fun StoreVisitorEvent(connection: Connection, vararg events: VisitorEvent): String { - return ActionStoreVisitorEvent(connection, TestUUIDValue, TestUUIDValue, events = events).copy(eventId = TestUUIDValue).serialize().verifyStoreVisitorEvent() - } + fun StoreVisitorEvent(connection: Connection, vararg events: VisitorEvent): String = ActionStoreVisitorEvent( + connection, + TestUUIDValue, + TestUUIDValue, + events = events + ) + .copy(eventId = TestUUIDValue) + .serialize() + .verifyStoreVisitorEvent() fun ExecuteTrigger(connection: Connection, id: UUID): String { return ActionExecuteTrigger(connection, TestUUIDValue, TestUUIDValue, id).copy(eventId = TestUUIDValue).serialize().verifyExecuteTrigger() @@ -229,8 +241,12 @@ internal object ServerRequest { .verifySendOutbound(storage.deviceToken) object StoreVisitorEvents { - fun CustomVisitorEvent(data: String, date: Date = Date(0)): VisitorEvent { - return VisitorEvent(type = VisitorEventType.ProactiveActionDisplayed, createdAt = date, data = data, id = TestUUIDValue) - } + fun CustomVisitorEvent(data: String, date: Date = Date(0)) = + VisitorEvent( + type = VisitorEventType.ProactiveActionDisplayed, + createdAt = date, + data = Json.Default.encodeToJsonElement(data), + id = TestUUIDValue + ) } } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequestAssertions.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequestAssertions.kt index 6f9ba06f..b6415d4f 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequestAssertions.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerRequestAssertions.kt @@ -27,7 +27,11 @@ import strucut.verifyStructureOf * Fixing these tests is permitted only in circumstances where the API * actually changes, not when this fails. Be warned. * */ -@Suppress("StringLiteralDuplication", "FunctionMaxLength") +@Suppress( + "StringLiteralDuplication", + "FunctionMaxLength", + "TooManyFunctions", +) internal object ServerRequestAssertions { private const val ChatWindowEvent = "chatWindowEvent" @@ -97,6 +101,9 @@ internal object ServerRequestAssertions { prop("authorizationCode") prop("codeVerifier") } + prop("disableChannelInfo", true) + prop("sdkPlatform", "android") + prop("sdkVersion") } } } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerResponse.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerResponse.kt index 3b6aa816..2a662b10 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerResponse.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/server/ServerResponse.kt @@ -22,6 +22,8 @@ import com.nice.cxonechat.AbstractChatTestSubstrate.Companion.TestUUID import com.nice.cxonechat.AbstractChatTestSubstrate.Companion.TestUUIDValue import com.nice.cxonechat.enums.ActionType import com.nice.cxonechat.enums.ActionType.CustomPopupBox +import com.nice.cxonechat.enums.ContactStatus +import com.nice.cxonechat.enums.ContactStatus.New import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.enums.EventType.CustomerAuthorized import com.nice.cxonechat.internal.model.AgentModel @@ -37,12 +39,14 @@ import com.nice.cxonechat.thread.CustomField import com.nice.cxonechat.tool.nextString import com.nice.cxonechat.tool.serialize import com.nice.cxonechat.util.DateTime -import java.lang.reflect.Type import java.util.Date import java.util.UUID @Suppress( - "unused" // serialization uses fields in objects + "unused", // serialization uses fields in objects + "TooManyFunctions", + "FunctionNaming", + "LongParameterList" ) internal object ServerResponse { @@ -97,7 +101,7 @@ internal object ServerResponse { object { val ident = key val value = value - val updatedAt = 0 + val updatedAt = Date(0) } } } @@ -201,7 +205,7 @@ internal object ServerResponse { val contact = object { val id = thread.contactId ?: TestContactId val threadIdOnExternalPlatform = TestUUID - val status = "New" + val status = "new" val createdAt = Date(0) val customFields = thread.fields.map(::CustomFieldModel) } @@ -308,7 +312,6 @@ internal object ServerResponse { fun MessageReadChanged( message: MessageModel, - temporaryTypeAdapters: Map = emptyMap(), ) = object { val eventId = TestUUID val eventType = "MessageReadChanged".also { assert(it == EventType.MessageReadChanged.value) } @@ -317,17 +320,21 @@ internal object ServerResponse { userStatistics = message.userStatistics.copy(readAt = Date(0)) ) } - }.serialize(temporaryTypeAdapters) + }.serialize() fun SetPositionInQueue( position: Int, - isAgentAvailable: Boolean + isAgentAvailable: Boolean, + threadId: UUID, ) = object { val eventId = TestUUID val eventType = "SetPositionInQueue".also { assert(it == EventType.SetPositionInQueue.value) } val data = object { val consumerContact = object { val id = TestContactId + val threadIdOnExternalPlatform = threadId + val status = ContactStatus.Pending.value + val createdAt = Date(0) } val routingQueue = object { val id = "a:b:c" @@ -376,6 +383,7 @@ internal object ServerResponse { thread: ChatThread = makeChatThread(), agent: AgentModel? = makeAgent(), customerCustomFields: List = emptyList(), + status: ContactStatus = New, vararg messages: MessageModel, ) = object { val eventId = TestUUID @@ -385,7 +393,7 @@ internal object ServerResponse { val contact = object { val id = thread.contactId ?: TestContactId val threadIdOnExternalPlatform = TestUUID - val status = "New" + val status = status val createdAt = Date(0) val customFields = thread.fields.map(::CustomFieldModel) } diff --git a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/tool/Serializer.kt b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/tool/Serializer.kt index 01fad3cc..f61cf2f1 100644 --- a/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/tool/Serializer.kt +++ b/chat-sdk-core/src/testDebug/java/com/nice/cxonechat/tool/Serializer.kt @@ -15,21 +15,105 @@ package com.nice.cxonechat.tool +import com.google.gson.FormattingStyle +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.internal.Streams +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken.NULL +import com.google.gson.stream.JsonToken.NUMBER +import com.google.gson.stream.JsonWriter import com.nice.cxonechat.internal.serializer.Default -import java.lang.reflect.Type - -internal fun Any.serialize(temporarySubtypes: Map = emptyMap()): String = Default.serializer - .let { gson -> - when { - temporarySubtypes.isNotEmpty() -> { - val builder = gson.newBuilder() - for ((type, adapter) in temporarySubtypes) { - builder.registerTypeAdapter(type, adapter) - } - builder.create() +import com.nice.cxonechat.util.timestampToDate +import com.nice.cxonechat.util.toTimestamp +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.serializer +import java.util.Date +import kotlin.math.roundToLong + +internal inline fun T.serialize(): String = if (this::class.java.isAnnotationPresent(Serializable::class.java)) { + Default.serializer.encodeToString(this) +} else { + Gson.toJson(this) +} + +internal val Gson = GsonBuilder() + .registerTypeAdapter(Date::class.java, DateTypeAdapter()) + .registerTypeAdapterFactory(Factory()) + .setFormattingStyle(FormattingStyle.PRETTY) + .create() + +private class DateTypeAdapter : TypeAdapter() { + + override fun write(out: JsonWriter, value: Date?) { + if (value == null) { + out.nullValue() + return + } + out.value(value.toTimestamp()) + } + + override fun read(reader: JsonReader): Date? { + return when (reader.peek()) { + NULL -> { + reader.nextNull() + null } - else -> gson + NUMBER -> { + var time = reader.nextDouble() + if (time < epochLimitSeconds) time *= 1000 + Date(time.roundToLong()) + } + + else -> reader.nextString().timestampToDate() + } + } + + companion object { + // this will make the program malfunction on Sat Nov 20 2286 17:46:40 UTC (: + private const val epochLimitSeconds = 10_000_000_000L + } +} + + +private class Factory : TypeAdapterFactory { + override fun create(gson: Gson, type: TypeToken): TypeAdapter { + val rawType: Class = type.rawType + val annotation: Serializable? = rawType.getAnnotation(Serializable::class.java) + + val annotationPresent: Boolean = rawType.isAnnotationPresent(Serializable::class.java) + + if (annotation != null && annotationPresent) { + return KotlinxAdapter(rawType) + } + + return gson.getDelegateAdapter(this, type) + } +} + +private class KotlinxAdapter(private val type: Class) : TypeAdapter() { + val serializer = Default.serializer.serializersModule.serializer(type) + override fun write(out: JsonWriter, value: T?) { + if (value == null) { + out.nullValue() + return + } + + out.jsonValue(Default.serializer.encodeToString(serializer, value)) + } + + override fun read(reader: JsonReader): T? { + return when (reader.peek()) { + NULL -> { + reader.nextNull() + null + } + else -> Default.serializer.decodeFromString(serializer, Streams.parse(reader).toString()) as T } } - .toJson(this) +} diff --git a/chat-sdk-ui/build.gradle b/chat-sdk-ui/build.gradle index bfb80e3d..c343acf3 100644 --- a/chat-sdk-ui/build.gradle +++ b/chat-sdk-ui/build.gradle @@ -22,6 +22,7 @@ plugins { id "android-test-conventions" id "android-library-style-conventions" id 'androidx.navigation.safeargs.kotlin' + id "org.jetbrains.kotlin.plugin.serialization" } android { @@ -50,9 +51,6 @@ android { } dependencies { - // GSON is used for parsing of payload in Plugin Custom messages - implementation libs.gson - // Handling of push notification sent via FCM implementation platform(libs.firebase.bom) implementation libs.firebase.messaging @@ -78,4 +76,7 @@ dependencies { // Immutable annotations implementation libs.findbugs + + // Kotlinx serialization + implementation libs.kotlinx.serialization.json } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/ChatActivity.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/ChatActivity.kt index c0fb640b..885d7c89 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/ChatActivity.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/ChatActivity.kt @@ -15,259 +15,376 @@ package com.nice.cxonechat.ui +import android.Manifest.permission +import android.annotation.SuppressLint import android.app.Activity +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.os.Bundle -import android.view.Menu -import android.view.WindowManager.LayoutParams -import androidx.annotation.AnimRes -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.SnackbarDuration.Short +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.findNavController -import androidx.navigation.fragment.NavHostFragment -import com.google.android.material.snackbar.Snackbar +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.createGraph +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nice.cxonechat.ChatMode.LiveChat import com.nice.cxonechat.ChatMode.MultiThread import com.nice.cxonechat.ChatState import com.nice.cxonechat.ChatState.Connected -import com.nice.cxonechat.ChatState.Connecting -import com.nice.cxonechat.ChatState.ConnectionLost import com.nice.cxonechat.ChatState.Initial -import com.nice.cxonechat.ChatState.Offline -import com.nice.cxonechat.ChatState.Prepared import com.nice.cxonechat.ChatState.Preparing import com.nice.cxonechat.ChatState.Ready -import com.nice.cxonechat.Public -import com.nice.cxonechat.exceptions.RuntimeChatException.AuthorizationError -import com.nice.cxonechat.prechat.PreChatSurvey +import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.ui.R.anim import com.nice.cxonechat.ui.R.string +import com.nice.cxonechat.ui.Screen.Offline +import com.nice.cxonechat.ui.Screen.ThreadList +import com.nice.cxonechat.ui.Screen.ThreadScreen +import com.nice.cxonechat.ui.composable.HandleChatErrorState +import com.nice.cxonechat.ui.composable.HandleChatState +import com.nice.cxonechat.ui.composable.HandleChatViewDialog +import com.nice.cxonechat.ui.composable.OfflineContentView +import com.nice.cxonechat.ui.composable.ThreadContentView +import com.nice.cxonechat.ui.composable.ThreadListContentView +import com.nice.cxonechat.ui.composable.conversation.ChatThreadTopBar +import com.nice.cxonechat.ui.composable.conversation.model.ConversationTopBarState +import com.nice.cxonechat.ui.composable.showCancellableSnackbar import com.nice.cxonechat.ui.composable.theme.ChatTheme -import com.nice.cxonechat.ui.databinding.ActivityMainBinding +import com.nice.cxonechat.ui.composable.theme.Fab +import com.nice.cxonechat.ui.composable.theme.Scaffold +import com.nice.cxonechat.ui.composable.theme.TopBar +import com.nice.cxonechat.ui.domain.AttachmentSharingRepository +import com.nice.cxonechat.ui.main.AudioRecordingViewModel import com.nice.cxonechat.ui.main.ChatStateViewModel +import com.nice.cxonechat.ui.main.ChatThreadViewModel import com.nice.cxonechat.ui.main.ChatThreadsViewModel import com.nice.cxonechat.ui.main.ChatViewModel -import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.None -import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.Survey -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.MultiThreadEnabled -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.NavigationFinished -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.SingleThreadCreated -import com.nice.cxonechat.ui.main.ChatViewModel.State -import com.nice.cxonechat.ui.main.ChatViewModel.State.CreateSingleThread -import com.nice.cxonechat.ui.main.ChatViewModel.State.SingleThreadCreationFailed -import com.nice.cxonechat.ui.main.ChatViewModel.State.SingleThreadPreChatSurveyRequired -import com.nice.cxonechat.ui.model.describe -import com.nice.cxonechat.ui.util.Ignored -import com.nice.cxonechat.ui.util.isEmpty -import com.nice.cxonechat.ui.util.showAlert -import kotlinx.coroutines.CoroutineScope +import com.nice.cxonechat.ui.storage.ValueStorage +import com.nice.cxonechat.ui.util.applyFixesForKeyboardInput +import com.nice.cxonechat.ui.util.checkNotificationPermissions +import com.nice.cxonechat.ui.util.checkPermissions +import com.nice.cxonechat.ui.util.contentDescription +import com.nice.cxonechat.ui.util.openWithAndroid +import com.nice.cxonechat.ui.util.overrideCloseAnimation +import com.nice.cxonechat.ui.util.overrideOpenAnimation +import com.nice.cxonechat.ui.util.parseThreadDeeplink +import com.nice.cxonechat.ui.util.repeatOnOwnerLifecycle +import com.nice.cxonechat.ui.util.showToast import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import java.util.UUID -import java.util.concurrent.CancellationException -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.Offline as NavigationOffline /** * Chat container activity. */ -@Public @Suppress("TooManyFunctions") -class ChatActivity : AppCompatActivity() { +class ChatActivity : ComponentActivity() { private val chatViewModel: ChatViewModel by viewModel() private val chatThreadsViewModel: ChatThreadsViewModel by viewModel() + private val chatThreadViewModel: ChatThreadViewModel by viewModel() private val chatStateViewModel: ChatStateViewModel by viewModel() - private val closing - get() = lifecycle.currentState == DESTROYED - - @Suppress("LateinitUsage") - private lateinit var binding: ActivityMainBinding - - private var chatStateSnackbar: Snackbar? = null - set(value) { - field?.dismiss() - field = value - } + private val audioViewModel: AudioRecordingViewModel by viewModel() + private val attachmentSharingRepository: AttachmentSharingRepository by inject() + private val valueStorage: ValueStorage by inject() + + private val requestPermissionLauncher: ActivityResultLauncher = getNotificationRequestResult() + private val audioRequestPermissionLauncher = getAudioRequestResult() + private val activityLauncher by lazy { + SelectAttachmentActivityLauncher(::sendAttachment, activityResultRegistry).also(lifecycle::addObserver) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - applyFixesForKeyboardInput() - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - + activityLauncher // activity launcher has to self-register before onStart setupComposableUi() + repeatOnOwnerLifecycle { intent.handleDeeplink() } + } - registerHandler(::handleChatState) - registerChatModelStateHandler() - registerHandler(::handleErrorStates) + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + repeatOnOwnerLifecycle { intent.handleDeeplink() } } - private fun registerChatModelStateHandler() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - var job: Job? = null - chatStateViewModel.state.collect { - job = if (listOf(Ready, Offline).contains(it)) { - handleChatModelState() - } else { - job?.cancel(CancellationException("State: $it")) - null - } + override fun onPause() { + super.onPause() + chatViewModel.close() + } + + @Composable + private fun BackgroundThreadUpdates(snackbarHostState: SnackbarHostState) { + chatThreadsViewModel.backgroundThreadsFlow.collectAsState(null).value?.let { thread -> + LaunchedEffect(thread) { + snackbarHostState.showSnackbar( + message = getString(string.background_thread_updated, thread.chatThread.threadName.orEmpty()), + duration = Short, + ) + } + } + } + + private fun sendAttachment(it: Uri) { + chatThreadViewModel.sendAttachment(it) + } + + override fun finish() { + super.finish() + overrideCloseAnimation(anim.dismiss_host, anim.dismiss_chat) + } + + private fun setupComposableUi() { + setContent { + ChatTheme { + val chatState by chatStateViewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + HandleEarlyChatState(snackbarHostState) { + ChatUi(chatState, snackbarHostState) } } } } - private fun CoroutineScope.handleChatModelState() = launch { - chatViewModel.state.collect { state -> - when (state) { - State.Initial -> Ignored - is NavigationState -> { - if (state is MultiThreadEnabled || state is NavigationFinished) { - observeBackgroundThreadUpdates() - } - startFragmentNavigation(state) - if (state is MultiThreadEnabled) { - intent?.handleDeeplink() - } + @Composable + private fun HandleEarlyChatState(snackbarHostState: SnackbarHostState, onChatReady: @Composable () -> Unit) { + val state by chatStateViewModel.state.collectAsState() + val context = LocalContext.current + when (state) { + // if the chat isn't prepared yet, prepare it. Hopefully it's been + // configured by the provider. + Initial, Preparing -> LaunchedEffect(state) { + snackbarHostState.currentSnackbarData?.dismiss() + if (state == Initial) { + chatViewModel.prepare(context) } + snackbarHostState.showCancellableSnackbar( + message = context.getString(string.preparing_sdk), + actionLabel = context.getString(string.cancel), + onAction = ::finish, + ) + } - CreateSingleThread -> chatViewModel.createThread() - is SingleThreadPreChatSurveyRequired -> chatViewModel.showPreChatSurvey(state.survey) - is SingleThreadCreationFailed -> showOnThreadCreationFailure(state) + else -> onChatReady() + } + } + + @Composable + private fun ChatUi(chatState: ChatState, snackbarHostState: SnackbarHostState) { + val isMultiThread = remember { chatViewModel.chatMode === MultiThread } + val isLiveChat = remember { chatViewModel.chatMode === LiveChat } + + val navController = rememberNavController() + val initialScreen = remember { getChatInitialScreen(isLiveChat, chatState, isMultiThread) } + val screenGraph = navController.createGraph(startDestination = initialScreen) { + composable { + OfflineView(snackbarHostState) + } + composable { + ThreadListView(snackbarHostState, navController) + } + composable { + ThreadView(snackbarHostState, isMultiThread, isLiveChat) } } + HandleChatViewDialog( + dialogShownFlow = chatViewModel.dialogShown, + cancelAction = ::finish, + submitAction = chatViewModel::respondToSurvey, + retryAction = chatViewModel::refreshThreadState + ) + HandleChatState( + snackbarHostState = snackbarHostState, + chatStateFlow = chatStateViewModel.state, + onConnectChatAction = chatViewModel::connect, + onReadyAction = { + navController.navigate(if (chatViewModel.chatMode === MultiThread) ThreadList else ThreadScreen) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + onFinishAction = ::finish, + onOfflineAction = { + navController.navigate(Offline) + }, + ) + HandleChatErrorState(snackbarHostState, chatStateViewModel.chatErrorState, ::finish) + NavHost(navController, screenGraph) } - private fun registerHandler( - handler: suspend () -> Unit, - repeatOnLifecycleState: Lifecycle.State = Lifecycle.State.RESUMED, - ) { - lifecycleScope.launch { - repeatOnLifecycle(repeatOnLifecycleState) { - handler() + @Composable + private fun ThreadView(snackbarHostState: SnackbarHostState, isMultiThread: Boolean, isLiveChat: Boolean) { + ChatTheme.Scaffold( + snackbarHostState = snackbarHostState, + topBar = { ThreadViewTopBar(isMultiThread, isLiveChat) } + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + ThreadContentView(snackbarHostState = snackbarHostState) + if (isMultiThread) BackgroundThreadUpdates(snackbarHostState) } } } - private suspend fun handleErrorStates() { - chatStateViewModel.chatErrorState.collect { - if (it is AuthorizationError) { - AlertDialog.Builder(this) - .setMessage(string.chat_state_error_default_message) - .setCancelable(false) - .setNeutralButton(string.chat_state_error_action_close) { _, _ -> finish() } - .setOnDismissListener { finish() } - .create() - .show() - } else { - chatStateSnackbar = Snackbar.make( - binding.root, - it.message ?: getText(string.chat_state_error_default_message), - Snackbar.LENGTH_SHORT - ).also(Snackbar::show) + @Composable + private fun ThreadListView(snackbarHostState: SnackbarHostState, navController: NavHostController) { + ChatTheme.Scaffold( + snackbarHostState = snackbarHostState, + topBar = { ChatTheme.TopBar(title = stringResource(id = string.thread_list_title)) }, + floatingActionButton = { ChatFab(chatThreadsViewModel::createThread) } + ) { + Box(modifier = Modifier.padding(it)) { + BackgroundThreadUpdates(snackbarHostState) + ThreadListContentView(chatThreadsViewModel) { + navController.navigate(ThreadScreen) + } } } } - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - intent?.handleDeeplink() + @Composable + private fun OfflineView(snackbarHostState: SnackbarHostState) { + BackHandler { + finish() + } + ChatTheme.Scaffold( + snackbarHostState = snackbarHostState, + topBar = { ChatTheme.TopBar(title = stringResource(id = string.offline)) } + ) { + Box(modifier = Modifier.padding(it)) { + OfflineContentView() } } } - override fun onPause() { - super.onPause() - chatViewModel.close() + private fun getChatInitialScreen(isLiveChat: Boolean, chatState: ChatState, isMultiThread: Boolean) = when { + isLiveChat && chatState === ChatState.Offline -> Offline + isMultiThread -> ThreadList + else -> ThreadScreen } - /** - * This is workaround for issue when keyboard is shown window content pans under the toolbar and keyboard overlaps - * window contents. - * There should be a better solution. - */ - @Suppress("DEPRECATION") - private fun applyFixesForKeyboardInput() { - if (VERSION.SDK_INT >= VERSION_CODES.R) window.setDecorFitsSystemWindows(true) - window.setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + @Composable + private fun ThreadViewTopBar(isMultiThread: Boolean, isLiveChat: Boolean) { + val threadNameFlow = remember { chatThreadViewModel.chatMetadata.map { it.threadName } } + ChatThreadTopBar( + conversationState = ConversationTopBarState( + threadName = threadNameFlow, + isMultiThreaded = isMultiThread, + isLiveChat = isLiveChat, + hasQuestions = chatThreadViewModel.hasQuestions, + isArchived = chatThreadViewModel.isArchived, + ), + onEditThreadName = { chatThreadViewModel.editThreadName() }, + onEditThreadValues = chatThreadViewModel::startEditingCustomValues, + onEndContact = chatThreadViewModel::endContact, + displayEndConversation = chatThreadViewModel::showEndContactDialog, + ) } - private fun CoroutineScope.observeBackgroundThreadUpdates() = launch { - chatThreadsViewModel.backgroundThreadsFlow.filterNotNull().collect { - Snackbar.make( - binding.root, - getString(string.background_thread_updated, it.chatThread.threadName.orEmpty()), - Snackbar.LENGTH_SHORT - ).show() + @SuppressLint( + "MissingPermission" // permission state is checked by `checkPermissions()` method + ) + private suspend fun onTriggerRecording(): Boolean { + if (!checkPermissions( + valueStorage = valueStorage, + permissions = requiredRecordAudioPermissions, + rationale = string.recording_audio_permission_rationale, + onAcceptPermissionRequest = audioRequestPermissionLauncher::launch + ) + ) { + return false + // Permissions will need to be sorted out first, user will have to click the button again after that + } + return if (audioViewModel.recordingFlow.value) { + audioViewModel.stopRecording(this@ChatActivity) + } else { + audioViewModel.startRecording(this@ChatActivity).isSuccess } } - override fun finish() { - super.finish() - - overrideCloseAnimation(anim.dismiss_host, anim.dismiss_chat) + @SuppressLint( + "MissingPermission" // permission state is checked by `checkPermissions()` method + ) + private fun onDismissRecording() { + lifecycleScope.launch { + if (!checkPermissions( + valueStorage = valueStorage, + permissions = requiredRecordAudioPermissions, + rationale = string.recording_audio_permission_rationale, + onAcceptPermissionRequest = audioRequestPermissionLauncher::launch + ) + ) { + return@launch + } + audioViewModel.deleteLastRecording(this@ChatActivity) { + showToast(string.record_audio_failed_cleanup, Toast.LENGTH_LONG) + } + } } - private fun setupComposableUi() { - binding.composeView.setContent { - ChatTheme { - when (val dialog = chatViewModel.dialogShown.collectAsState().value) { - None -> Ignored - is Survey -> BuildPreChatSurveyDialog(survey = dialog.survey) + private fun onShare(attachments: Collection) { + chatThreadViewModel.beginPrepareAttachments() + lifecycleScope.launch(Dispatchers.IO) { + val intent = attachmentSharingRepository.createSharingIntent(attachments, this@ChatActivity) + chatThreadViewModel.finishPrepareAttachments() + lifecycleScope.launch(Dispatchers.Main) { + if (intent == null) { + showToast(string.prepare_attachments_failure) + } else { + startActivity(Intent.createChooser(intent, null)) } } } } - @Composable - private fun BuildPreChatSurveyDialog(survey: PreChatSurvey) { - PreChatSurveyDialog( - survey = survey, - onCancel = ::finish, - onValidSurveySubmission = chatViewModel::respondToSurvey, - ) - } - - private fun startFragmentNavigation(state: NavigationState) { - val navigationStart = when (state) { - NavigationOffline -> R.navigation.offline - MultiThreadEnabled -> R.navigation.threads - SingleThreadCreated -> R.navigation.chat - NavigationFinished -> return - } - - val navHostFragment = NavHostFragment.create(navigationStart) - supportFragmentManager.beginTransaction() - .replace(R.id.nav_host_fragment, navHostFragment) - .setPrimaryNavigationFragment(navHostFragment) - .commitNow() - - navHostFragment.navController.addOnDestinationChangedListener { _, _, _ -> - invalidateOptionsMenu() + private fun onAttachmentClicked(attachment: Attachment) { + val url = attachment.url + val mimeType = attachment.mimeType.orEmpty() + val title by lazy { attachment.contentDescription } + when { + mimeType.startsWith("image/") -> chatThreadViewModel.showImage(url, title ?: getString(string.image_preview_title)) + mimeType.startsWith("video/") -> chatThreadViewModel.showVideo(url, title ?: getString(string.video_preview_title)) + mimeType.startsWith("audio/") -> chatThreadViewModel.playAudio(url, title) + else -> openWithAndroid(attachment) } + } - chatViewModel.setNavigationFinishedState() - - invalidateOptionsMenu() + private fun openWithAndroid(attachment: Attachment) { + if (!openWithAndroid(attachment.url, attachment.mimeType)) showInvalidAttachmentDialog(attachment) } override fun onResume() { @@ -277,92 +394,19 @@ class ChatActivity : AppCompatActivity() { chatViewModel.reportOnResume() } } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.default_menu, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - if (menu != null && chatStateViewModel.state.value == Connected) { - val navController = findNavController(R.id.nav_host_fragment) - val isInChat = navController.currentDestination?.id == R.id.offlineFragment - val isMultiThread = chatViewModel.chatMode === MultiThread - val isLiveChat = chatViewModel.chatMode === LiveChat - val hasQuestions = chatViewModel.preChatSurvey?.fields?.isEmpty() == false - - with(menu) { - findItem(R.id.action_thread_name)?.isVisible = isInChat && isMultiThread - findItem(R.id.action_custom_values)?.isVisible = isInChat && hasQuestions - findItem(R.id.action_end_contact)?.isVisible = isInChat && isLiveChat - } + lifecycleScope.launch { + chatStateViewModel.state.filter { it === Ready }.first() + chatViewModel.refreshThreadState() } - - return super.onPrepareOptionsMenu(menu) - } - - private suspend fun handleChatState() { - chatStateViewModel.state.collect { state: ChatState -> - when (state) { - // if the chat isn't prepared yet, prepare it. Hopefully it's been - // configured by the provider. - Initial -> chatViewModel.prepare(applicationContext) - - Preparing -> chatStateSnackbar = Snackbar.make( - binding.root, - getString(string.preparing_sdk), - Snackbar.LENGTH_INDEFINITE - ).setAction(string.chat_state_connecting_action_cancel) { - finish() - }.apply(Snackbar::show) - - // if the chat is (or becomes) prepared, then start a connect attempt - Prepared -> if (!closing) { - chatViewModel.connect() - } - - Connecting -> chatStateSnackbar = Snackbar.make( - binding.root, - getString(string.chat_state_connecting), - Snackbar.LENGTH_INDEFINITE - ).setAction(string.chat_state_connecting_action_cancel) { - finish() - }.apply(Snackbar::show) - - Connected -> chatStateSnackbar = Snackbar.make( - binding.root, - string.chat_state_connected, - Snackbar.LENGTH_SHORT - ).apply(Snackbar::show) - - Ready -> chatStateSnackbar = Snackbar.make( - binding.root, - "SDK ready", - Snackbar.LENGTH_SHORT - ).apply(Snackbar::show) - - Offline -> chatStateSnackbar = Snackbar.make( - binding.root, - "SDK OFFLINE", - Snackbar.LENGTH_SHORT - ) - - ConnectionLost -> chatStateSnackbar = Snackbar.make( - binding.root, - string.chat_state_connection_lost, - Snackbar.LENGTH_INDEFINITE - ).setAction(string.chat_state_connection_lost_action_reconnect) { - chatViewModel.connect() - }.apply(Snackbar::show) - } + if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { + checkNotificationPermissions( + permission.POST_NOTIFICATIONS, + string.notifications_rationale, + requestPermissionLauncher::launch + ) } } - private fun showOnThreadCreationFailure(state: SingleThreadCreationFailed) { - showAlert(describe(state.failure), onClick = chatViewModel::refreshThreadState) - } - private suspend fun Intent.handleDeeplink() { val data = data ?: return withContext(Dispatchers.Default) { @@ -373,31 +417,29 @@ class ChatActivity : AppCompatActivity() { } companion object { - private fun Activity.overrideOpenAnimation( - @AnimRes enterAnim: Int, - @AnimRes exitAnim: Int, - ) { - if (VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { - @Suppress("DEPRECATION") - overridePendingTransition(enterAnim, exitAnim) - } else { - overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, enterAnim, exitAnim) - } + private val requiredRecordAudioPermissions = if (VERSION.SDK_INT > VERSION_CODES.Q) { + setOf(permission.RECORD_AUDIO) + } else { + setOf(permission.RECORD_AUDIO, permission.WRITE_EXTERNAL_STORAGE) } - /* - * This could be defined as a normal method on ChatActivity, but this seems to keep it paired with - * overrideCloseAnimation better. - */ - private fun Activity.overrideCloseAnimation( - @AnimRes enterAnim: Int, - @AnimRes exitAnim: Int, - ) { - if (VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { - @Suppress("DEPRECATION") - overridePendingTransition(enterAnim, exitAnim) - } else { - overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, enterAnim, exitAnim) + @Composable + private fun ChatActivity.ThreadContentView(snackbarHostState: SnackbarHostState) { + ThreadContentView( + onAttachmentClicked = ::onAttachmentClicked, + onShare = ::onShare, + closeChat = ::finish, + onDismissRecording = ::onDismissRecording, + onTriggerRecording = ::onTriggerRecording, + chatThreadViewModel = chatThreadViewModel, + chatViewModel = chatViewModel, + audioViewModel = audioViewModel, + snackbarHostState = snackbarHostState, + activityLauncher = activityLauncher, + ) + val chatThreadsState by chatThreadsViewModel.state.collectAsState() + if (chatThreadsState === ChatThreadsViewModel.State.ThreadSelected) { + chatThreadsViewModel.resetState() } } @@ -406,6 +448,7 @@ class ChatActivity : AppCompatActivity() { * * @param from Activity to use as a base for the new [ChatActivity]. */ + @JvmStatic fun startChat(from: Activity) { from.startActivity(Intent(from, ChatActivity::class.java)) from.overrideOpenAnimation(anim.present_chat, anim.present_host) @@ -413,8 +456,55 @@ class ChatActivity : AppCompatActivity() { } } -private fun Uri.parseThreadDeeplink(): Result = runCatching { - val threadIdString = getQueryParameter("idOnExternalPlatform") - require(!threadIdString.isNullOrEmpty()) { "Invalid threadId in $this" } - UUID.fromString(threadIdString) +private fun ComponentActivity.getNotificationRequestResult() = + registerForActivityResult(RequestPermission()) { isGranted -> + if (!isGranted) { + MaterialAlertDialogBuilder(this) + .setTitle(string.no_notifications_title) + .setMessage(string.no_notifications_message) + .setNeutralButton(string.ok, null) + .show() + } + } + +private fun ComponentActivity.getAudioRequestResult() = + registerForActivityResult(RequestMultiplePermissions()) { requestResults: Map? -> + if (requestResults.orEmpty().any { !it.value }) { + MaterialAlertDialogBuilder(this) + .setTitle(string.recording_audio_permission_denied_title) + .setMessage(string.recording_audio_permission_denied_body) + .setNeutralButton(string.ok) { dialog, _ -> + dialog.dismiss() + } + } + } + +private fun Context.showInvalidAttachmentDialog(attachment: Attachment) { + MaterialAlertDialogBuilder(this) + .setTitle(string.unsupported_type_title) + .setMessage(getString(string.unsupported_type_message, attachment.mimeType)) + .setNegativeButton(string.cancel, null) + .show() +} + +@Composable +private fun ChatFab(onClick: () -> Unit = {}) { + ChatTheme.Fab( + onClick = onClick, + icon = rememberVectorPainter(image = Icons.Default.Add), + contentDescription = null, + ) +} + +@Serializable +private sealed class Screen { + + @Serializable + data object Offline : Screen() + + @Serializable + data object ThreadList : Screen() + + @Serializable + data object ThreadScreen : Screen() } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PreChatSurveyDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PreChatSurveyDialog.kt index b0ef5534..9228303e 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PreChatSurveyDialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PreChatSurveyDialog.kt @@ -16,7 +16,7 @@ package com.nice.cxonechat.ui import android.widget.Toast -import androidx.compose.material.Surface +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/SelectAttachmentActivityLauncher.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/SelectAttachmentActivityLauncher.kt new file mode 100644 index 00000000..cfb39559 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/SelectAttachmentActivityLauncher.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui + +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts.GetContent +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +/** + * This class is responsible for launching a system activity which will report selected attachment(s) + * via the provided [sendAttachment] callback. + * + * The [onCreate] method must be called from the owning activity's onCreate method (before the activity is in state Started). + */ +internal class SelectAttachmentActivityLauncher( + private val sendAttachment: (uri: Uri) -> Unit, + private val registry: ActivityResultRegistry +) : DefaultLifecycleObserver { + private var getContent: ActivityResultLauncher? = null + private var getDocument: ActivityResultLauncher>? = null + + override fun onCreate(owner: LifecycleOwner) { + getContent = registry.register("com.nice.cxonechat.ui.content", owner, GetContent()) { uri -> + val safeUri = uri ?: return@register + sendAttachment(safeUri) + } + getDocument = registry.register("com.nice.cxonechat.ui.document", owner, OpenDocument()) { uri -> + val safeUri = uri ?: return@register + sendAttachment(safeUri) + } + } + + /** + * start a foreign activity to find an attachment with the indicated mime types + * + * [mimeTypes] is one of the strings supplied by the chat instance. + * + * Note that this will work for finding existing resources, but not for opening + * the camera for photos or videos. + * + * @param mimeTypes attachment types to find. + * + */ + fun getDocument(mimeTypes: Array) { + if (mimeTypes.size == 1) { + getContent?.launch(mimeTypes[0]) + } else { + getDocument?.launch(mimeTypes) + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/CustomPopupView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/CustomPopupView.kt new file mode 100644 index 00000000..8569ff75 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/CustomPopupView.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable + +import android.widget.Toast +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nice.cxonechat.ui.composable.generic.showActionSnackbar +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.composable.theme.Scaffold +import com.nice.cxonechat.ui.main.ChatThreadViewModel +import com.nice.cxonechat.ui.main.ChatThreadViewModel.PopupActionState.PopupActionData +import com.nice.cxonechat.ui.main.ChatThreadViewModel.PopupActionState.PreviewPopupAction +import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.Failure +import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.Success +import com.nice.cxonechat.ui.util.toJsonElement +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement + +@Composable +internal fun CustomPopUpView( + snackbarHostState: SnackbarHostState, + chatThreadViewModel: ChatThreadViewModel, +) { + PopUpViewInternal( + snackbarHostState = snackbarHostState, + actionState = chatThreadViewModel.actionState, + onPopupActionClicked = chatThreadViewModel::reportOnPopupActionClicked, + onPopupAction = chatThreadViewModel::reportOnPopupAction, + onPopupActionDisplayed = chatThreadViewModel::reportOnPopupActionDisplayed, + ) +} + +@Composable +private inline fun PopUpViewInternal( + snackbarHostState: SnackbarHostState, + actionState: StateFlow, + crossinline onPopupActionClicked: (T) -> Unit, + crossinline onPopupAction: (ChatThreadViewModel.ReportOnPopupAction, T) -> Unit, + crossinline onPopupActionDisplayed: (T) -> Unit, +) { + actionState + .filterIsInstance() + .collectAsStateWithLifecycle(null) + .value + ?.let { action -> + val rawVariables = action.variables + runCatching { + val variables = rawVariables.toJsonElement() + Json.decodeFromJsonElement(variables) + }.onSuccess { popupData -> + LaunchedEffect(popupData) { + snackbarHostState.showActionSnackbar( + message = popupData.bodyText, + actionLabel = popupData.action.actionText, + onAction = { + onPopupActionClicked(action) + onPopupAction(Success, action) + }, + onDismiss = { + onPopupAction(Failure, action) + } + ) + onPopupActionDisplayed(action) + } + }.onFailure { + Text(it.message.orEmpty()) + Toast.makeText( + LocalContext.current, + "Unable to decode ReceivedOnPopupAction", + Toast.LENGTH_SHORT + ).show() + } + } +} + +@Serializable +internal data class CustomPopupData( + @SerialName("headingText") val headingText: String, + @SerialName("bodyText") val bodyText: String, + @SerialName("action") val action: ActionData, +) + +@Serializable +internal data class ActionData( + @SerialName("text") val actionText: String, + @SerialName("url") val actionUrl: String, +) + +// Preview + +// Preview works in the interactive mode only +@Preview +@Composable +private fun PopUpViewPreview() { + val actionState = remember { MutableStateFlow(nextPopupAction()) } + ChatTheme { + val snackbarHostState = remember { SnackbarHostState() } + ChatTheme.Scaffold( + snackbarHostState = snackbarHostState, + ) { + PopUpViewInternal( + snackbarHostState = snackbarHostState, + actionState = actionState, + onPopupActionClicked = {}, + onPopupAction = { _, _ -> }, + onPopupActionDisplayed = {} + ) + } + } +} + +private fun nextPopupAction() = PreviewPopupAction( + variables = mapOf( + "headingText" to "Heading", + "bodyText" to "Body", + "action" to mapOf( + "text" to "Action", + "url" to "https://example.com" + ) + ), + metadata = object {} +) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatErrorState.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatErrorState.kt new file mode 100644 index 00000000..e2dc2183 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatErrorState.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.SnackbarDuration.Short +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nice.cxonechat.exceptions.RuntimeChatException +import com.nice.cxonechat.exceptions.RuntimeChatException.AuthorizationError +import com.nice.cxonechat.exceptions.RuntimeChatException.ServerCommunicationError +import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.composable.theme.Scaffold +import com.nice.cxonechat.ui.util.Ignored +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull + +/** + * Display chat errors to the user. + * If the error [AuthorizationError] user will be notified that the chat will be terminated and the [onTerminalError] will be called. + */ +@Composable +internal fun HandleChatErrorState( + snackbarHostState: SnackbarHostState, + chatErrorStateFlow: Flow, + onTerminalError: () -> Unit, +) { + val context = LocalContext.current + when (val error = chatErrorStateFlow.collectAsStateWithLifecycle(null).value) { + null -> Ignored + + is AuthorizationError -> AlertDialog( + onDismissRequest = { + onTerminalError() + }, + text = { Text(stringResource(R.string.chat_state_error_default_message)) }, + confirmButton = { + TextButton(onClick = onTerminalError) { + Text(stringResource(R.string.chat_state_error_action_close)) + } + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ) + ) + + else -> LaunchedEffect(error) { + snackbarHostState.showSnackbar( + message = context.getString(R.string.chat_state_error_default_message), + actionLabel = context.getString(R.string.dismiss), + duration = Short, + ) + } + } +} + +@Composable +@Preview +private fun HandleChatErrorPreview() { + ChatTheme { + val snackbarHostState = remember { SnackbarHostState() } + val chatErrorStateFlow: MutableStateFlow = remember { MutableStateFlow(getServerErr()) } + val filteredFlow = remember { chatErrorStateFlow.filterNotNull() } + ChatTheme.Scaffold( + snackbarHostState = snackbarHostState, + ) { + Column { + Row { + TextButton(onClick = { chatErrorStateFlow.value = getServerErr() }) { + Text("Server error") + } + TextButton(onClick = { chatErrorStateFlow.value = getAuthErr() }) { + Text("Auth error") + } + } + HandleChatErrorState( + snackbarHostState = snackbarHostState, + chatErrorStateFlow = filteredFlow, + onTerminalError = { + chatErrorStateFlow.value = getServerErr() + } + ) + } + } + } +} + +private fun getAuthErr(): AuthorizationError = AuthorizationError::class.java.declaredConstructors + .first() + .newInstance("Auth err") as AuthorizationError + +private fun getServerErr(): ServerCommunicationError = ServerCommunicationError::class.java.declaredConstructors + .first() + .newInstance("Server err") as ServerCommunicationError diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatState.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatState.kt new file mode 100644 index 00000000..3b8410e6 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatState.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.SnackbarDuration.Short +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.currentStateAsState +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.nice.cxonechat.ChatState +import com.nice.cxonechat.ChatState.Connected +import com.nice.cxonechat.ChatState.Connecting +import com.nice.cxonechat.ChatState.ConnectionLost +import com.nice.cxonechat.ChatState.Initial +import com.nice.cxonechat.ChatState.Offline +import com.nice.cxonechat.ChatState.Prepared +import com.nice.cxonechat.ChatState.Preparing +import com.nice.cxonechat.ChatState.Ready +import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.composable.generic.showActionSnackbar +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.composable.theme.Scaffold +import com.nice.cxonechat.ui.util.Ignored +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Display a snackbar based on the current chat state. + */ +@Composable +internal fun HandleChatState( + snackbarHostState: SnackbarHostState, + chatStateFlow: StateFlow, + onConnectChatAction: () -> Unit, + onReadyAction: () -> Unit, + onFinishAction: () -> Unit, + onOfflineAction: () -> Unit, +) { + val context = LocalContext.current + val state by chatStateFlow.collectAsStateWithLifecycle(null) + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateAsState() + LaunchedEffect(state) { + snackbarHostState.currentSnackbarData?.dismiss() + when (state) { + // Initial and Preparing states are handled separately + null, Initial, Preparing -> Ignored + + // if the chat is (or becomes) prepared, then start a connect attempt + Prepared -> if (lifecycleState.isAtLeast(State.STARTED)) { + onConnectChatAction() + } + + Connecting -> snackbarHostState.showCancellableSnackbar( + message = context.getString(R.string.chat_state_connecting), + actionLabel = context.getString(R.string.cancel), + onAction = onFinishAction, + ) + + Connected -> snackbarHostState.showSnackbar( + message = context.getString(R.string.chat_state_connected), + duration = Short, + ) + + Ready -> snackbarHostState.showSnackbar( + "SDK ready", + duration = Short, + ).also { + onReadyAction() + } + + // TODO DE-87750: Figure out how to handle this properly. + Offline -> { + onOfflineAction() + snackbarHostState.showSnackbar( + "SDK OFFLINE", + duration = Short, + ).also { onConnectChatAction() } + } + + ConnectionLost -> snackbarHostState.showActionSnackbar( + message = context.getString(R.string.chat_state_connection_lost), + actionLabel = context.getString(R.string.chat_state_connection_lost_action_reconnect), + onAction = onConnectChatAction + ) + } + } +} + +@Composable +@Preview +private fun PreviewHandleChatState() { + ChatTheme { + val chatStateFlow = remember { MutableStateFlow(Prepared) } + val chatState by chatStateFlow.collectAsStateWithLifecycle(Initial) + val snackbarHostState = remember { SnackbarHostState() } + val lifecycleOwner = LocalLifecycleOwner.current + + ChatTheme.Scaffold( + snackbarHostState = snackbarHostState, + ) { + Column(modifier = Modifier.padding(it)) { + Text("Chat State: $chatState") + LazyVerticalGrid( + columns = GridCells.Fixed(2), + ) { + items(ChatState.entries.count(), key = { index -> ChatState.entries[index] }) { index -> + val state = ChatState.entries[index] + TextButton(onClick = { chatStateFlow.value = state }) { + Text(state.name) + } + } + } + } + HandleChatState( + snackbarHostState = snackbarHostState, + chatStateFlow = chatStateFlow, + onConnectChatAction = { showSnackbar(lifecycleOwner, snackbarHostState, "onConnectChatAction") }, + onReadyAction = { showSnackbar(lifecycleOwner, snackbarHostState, "onReadyAction") }, + onFinishAction = { showSnackbar(lifecycleOwner, snackbarHostState, "onFinishAction") }, + onOfflineAction = { showSnackbar(lifecycleOwner, snackbarHostState, "onOfflineAction") }, + ) + } + } +} + +private fun showSnackbar( + lifecycleOwner: LifecycleOwner, + snackbarHostState: SnackbarHostState, + message: String, +) { + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(State.STARTED) { + snackbarHostState.showSnackbar(message, duration = Short) + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatViewDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatViewDialog.kt new file mode 100644 index 00000000..d45aac83 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/HandleChatViewDialog.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.nice.cxonechat.prechat.PreChatSurvey +import com.nice.cxonechat.state.FieldDefinition +import com.nice.cxonechat.state.FieldDefinitionList +import com.nice.cxonechat.ui.PreChatSurveyDialog +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs +import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.None +import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.Survey +import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.ThreadCreationFailed +import com.nice.cxonechat.ui.model.describe +import com.nice.cxonechat.ui.model.prechat.PreChatResponse +import com.nice.cxonechat.ui.util.Ignored +import com.nice.cxonechat.ui.util.showAlert +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID + +/** + * Display dialog used for thread creation based on the state of the [com.nice.cxonechat.ui.main.ChatViewModel.Dialogs] flow. + */ +@Composable +internal fun HandleChatViewDialog( + dialogShownFlow: StateFlow, + cancelAction: () -> Unit, + submitAction: (Sequence) -> Unit = {}, + retryAction: () -> Unit, +) { + val context = LocalContext.current + when (val dialog = dialogShownFlow.collectAsState().value) { + None -> Ignored + is Survey -> PreChatSurveyDialog( + survey = dialog.survey, + onCancel = cancelAction, + onValidSurveySubmission = submitAction, + ) + + is ThreadCreationFailed -> context.showAlert(context.describe(dialog.failure), onClick = retryAction) + } +} + +@Composable +@Preview +private fun ChatDialogPreview() { + val dialogFlow: MutableStateFlow = remember { MutableStateFlow(None) } + ChatTheme { + Column { + Row { + TextButton(onClick = { + dialogFlow.value = None + }) { + Text("None") + } + TextButton(onClick = { dialogFlow.value = Survey(Survey) }) { + Text("Survey") + } + // MaterialAlertDialogBuilder cannot be previewed + } + HandleChatViewDialog( + dialogShownFlow = dialogFlow, + cancelAction = { dialogFlow.value = None }, + submitAction = { dialogFlow.value = None }, + retryAction = { dialogFlow.value = None }, + ) + } + } +} + +private object Survey : PreChatSurvey { + override val name = "PreChat Survey" + override val fields: FieldDefinitionList = sequenceOf( + object : FieldDefinition.Text { + override val fieldId = UUID.randomUUID().toString() + override val label = "Name" + override val isRequired = false + override val isEMail = false + + override fun validate(value: String) = Unit + }, + object : FieldDefinition.Text { + override val fieldId = UUID.randomUUID().toString() + override val label = "EMail" + override val isRequired = true + override val isEMail = true + + override fun validate(value: String) = Unit + } + ) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadsFragment.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/MultiThreadContent.kt similarity index 66% rename from chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadsFragment.kt rename to chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/MultiThreadContent.kt index 2f6490ce..6abcc1a1 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadsFragment.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/MultiThreadContent.kt @@ -13,32 +13,27 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -package com.nice.cxonechat.ui.main +package com.nice.cxonechat.ui.composable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.DismissValue.DismissedToStart -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.rememberDismissState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.collectAsState @@ -52,16 +47,12 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle.State -import androidx.navigation.fragment.findNavController import com.nice.cxonechat.message.Message import com.nice.cxonechat.prechat.PreChatSurvey import com.nice.cxonechat.state.FieldDefinition @@ -78,13 +69,11 @@ import com.nice.cxonechat.ui.composable.generic.AgentAvatar import com.nice.cxonechat.ui.composable.theme.Alert import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.ChatTheme.chatTypography -import com.nice.cxonechat.ui.composable.theme.ChatTheme.colors +import com.nice.cxonechat.ui.composable.theme.ChatTheme.colorScheme import com.nice.cxonechat.ui.composable.theme.ChatTheme.space -import com.nice.cxonechat.ui.composable.theme.Fab import com.nice.cxonechat.ui.composable.theme.MultiToggleButton -import com.nice.cxonechat.ui.composable.theme.Scaffold import com.nice.cxonechat.ui.composable.theme.SwipeToDismiss -import com.nice.cxonechat.ui.composable.theme.TopBar +import com.nice.cxonechat.ui.main.ChatThreadsViewModel.State import com.nice.cxonechat.ui.main.ChatThreadsViewModel.State.Initial import com.nice.cxonechat.ui.main.ChatThreadsViewModel.State.ThreadPreChatSurveyRequired import com.nice.cxonechat.ui.main.ChatThreadsViewModel.State.ThreadSelected @@ -94,108 +83,69 @@ import com.nice.cxonechat.ui.model.Thread import com.nice.cxonechat.ui.model.describe import com.nice.cxonechat.ui.model.prechat.PreChatResponse import com.nice.cxonechat.ui.util.Ignored -import com.nice.cxonechat.ui.util.repeatOnViewOwnerLifecycle import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.util.UUID import kotlin.random.Random -/** - * Fragment displaying the list of available chat threads. - */ -class ChatThreadsFragment : Fragment() { - private val chatThreadsViewModel: ChatThreadsViewModel by activityViewModel() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - repeatOnViewOwnerLifecycle(State.STARTED) { - chatThreadsViewModel.state.collect { state -> - when (state) { - /* normal state */ - Initial -> Ignored - - /* viewModel signaling to transition to thread view */ - ThreadSelected -> navigateToThread() - - /* most states correspond to alerts to display and are handled via compose */ - is ThreadPreChatSurveyRequired -> Ignored - } - } - } - - return ComposeView(requireContext()).apply { - setContent { - ChatThreadsFragmentView( - state = chatThreadsViewModel.state.collectAsState().value, - threads = chatThreadsViewModel.threads.collectAsState().value, - threadFailure = chatThreadsViewModel.createThreadFailure.collectAsState().value, - onThreadSelected = chatThreadsViewModel::selectThread, - onCreateThread = chatThreadsViewModel::createThread, - onArchiveThread = chatThreadsViewModel::archiveThread, - resetState = chatThreadsViewModel::resetState, - respondToSurvey = chatThreadsViewModel::respondToSurvey, - resetCreateThreadState = chatThreadsViewModel::resetCreateThreadState - ) - } - } - } - - override fun onResume() { - super.onResume() - activity?.title = getString(string.thread_list_title) - chatThreadsViewModel.refreshThreads() - } - - private fun navigateToThread() { - val destination = ChatThreadsFragmentDirections.actionChatThreadsFragmentToChat() - findNavController().navigate(destination) - chatThreadsViewModel.resetState() - } -} - @Composable -private fun ChatThreadsFragmentView( - state: ChatThreadsViewModel.State, +internal fun MultiThreadContent( threads: List, - threadFailure: Failure?, onThreadSelected: (Thread) -> Unit, - onCreateThread: () -> Unit, onArchiveThread: (Thread) -> Unit, + state: State, + threadFailure: Failure?, resetState: () -> Unit, respondToSurvey: (Sequence) -> Unit, resetCreateThreadState: () -> Unit, ) { - ChatTheme { - ChatTheme.Scaffold( - topBar = { ChatTheme.TopBar(title = stringResource(id = string.thread_list_title)) }, - floatingActionButton = { - ChatTheme.Fab(rememberVectorPainter(image = Icons.Default.Add), null, onClick = onCreateThread) + Column { + var showArchivedThreads by rememberSaveable { mutableStateOf(false) } + + ActiveThreadToggle(showArchivedThreads) { + showArchivedThreads = !showArchivedThreads + } + + ChatThreadListView( + threads = threads.filter { + it.chatThread.canAddMoreMessages != showArchivedThreads }, - ) { - Column { - var showArchivedThreads by rememberSaveable { mutableStateOf(false) } + onThreadSelected = onThreadSelected, + onArchiveThread = onArchiveThread + ) + } - ActiveThreadToggle(showArchivedThreads) { - showArchivedThreads = !showArchivedThreads - } + ChatThreadsStateAlert( + state = state, + threadFailure = threadFailure, + resetState = resetState, + respondToSurvey = respondToSurvey, + resetCreateThreadState = resetCreateThreadState + ) +} - ChatThreadListView( - threads = threads.filter { - it.chatThread.canAddMoreMessages != showArchivedThreads - }, - onThreadSelected = onThreadSelected, - onArchiveThread = onArchiveThread - ) - } +@Composable +private fun ActiveThreadToggle( + showArchivedThreads: Boolean, + modifier: Modifier = Modifier, + onValueChanged: (Boolean) -> Unit, +) { + val context = LocalContext.current + val states = remember { context.resources.getStringArray(array.thread_state_names).toList() } - ChatThreadsStateAlert( - state = state, - threadFailure = threadFailure, - resetState = resetState, - respondToSurvey = respondToSurvey, - resetCreateThreadState = resetCreateThreadState - ) + Row( + modifier = modifier + .fillMaxWidth() + .padding(space.defaultPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = CenterVertically, + ) { + ChatTheme.MultiToggleButton( + currentSelection = states[if (showArchivedThreads) 1 else 0], + toggleStates = states, + ) { + onValueChanged(it != states[0]) } } } @@ -223,27 +173,65 @@ private fun ChatThreadListView( ) { ChatThreadView(thread = thread, onThreadSelected = onThreadSelected) } - Divider() + HorizontalDivider() } } } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatThreadsStateAlert( + state: State, + threadFailure: Failure?, + resetState: () -> Unit, + respondToSurvey: (Sequence) -> Unit, + resetCreateThreadState: () -> Unit, +) { + when (state) { + /* nothing to do */ + Initial -> Ignored + + is ThreadPreChatSurveyRequired -> { + PreChatSurveyDialog( + survey = state.survey, + onCancel = resetState, + onValidSurveySubmission = respondToSurvey, + ) + + threadFailure?.let { failure -> + val context = LocalContext.current + + ChatTheme.Alert( + context.describe(failure), + onDismiss = resetCreateThreadState, + dismissLabel = context.getString(string.cancel), + ) + } + } + + /* handled by state monitor */ + ThreadSelected -> Ignored + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatThreadWrapper( thread: Thread, onArchiveThread: (Thread) -> Unit, - content: @Composable RowScope.() -> Unit + content: @Composable ColumnScope.() -> Unit ) { if (thread.chatThread.canAddMoreMessages) { val currentThread by rememberUpdatedState(newValue = thread) - val dismissState = rememberDismissState { value -> - if (value == DismissedToStart) { - onArchiveThread(currentThread) - } - /* always return false to reset state even though thread moves to a different list */ - false - } + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == EndToStart) { + onArchiveThread(currentThread) + } + /* always return false to reset state even though thread moves to a different list */ + false + }, + ) ChatTheme.SwipeToDismiss( dismissState = dismissState, @@ -253,7 +241,7 @@ private fun ChatThreadWrapper( content = content ) } else { - Row(modifier = Modifier.fillMaxWidth(), content = content) + Column(modifier = Modifier.fillMaxWidth(), content = content) } } @@ -291,70 +279,11 @@ private fun DetailsChevron(modifier: Modifier = Modifier) { painter = rememberVectorPainter(image = Icons.Default.ChevronRight), contentDescription = null, modifier = modifier, - colorFilter = ColorFilter.tint(colors.onBackground) + colorFilter = ColorFilter.tint(colorScheme.onBackground) ) } -@Composable -private fun ActiveThreadToggle( - showArchivedThreads: Boolean, - modifier: Modifier = Modifier, - onValueChanged: (Boolean) -> Unit -) { - val context = LocalContext.current - val states = remember { context.resources.getStringArray(array.thread_state_names).toList() } - - Row( - modifier = modifier - .fillMaxWidth() - .padding(space.defaultPadding), - horizontalArrangement = Arrangement.Center, - verticalAlignment = CenterVertically, - ) { - ChatTheme.MultiToggleButton( - currentSelection = states[if (showArchivedThreads) 1 else 0], - toggleStates = states, - ) { - onValueChanged(it != states[0]) - } - } -} - -@Composable -private fun ChatThreadsStateAlert( - state: ChatThreadsViewModel.State, - threadFailure: Failure?, - resetState: () -> Unit, - respondToSurvey: (Sequence) -> Unit, - resetCreateThreadState: () -> Unit, -) { - when (state) { - /* nothing to do */ - Initial -> Ignored - - is ThreadPreChatSurveyRequired -> { - PreChatSurveyDialog( - survey = state.survey, - onCancel = resetState, - onValidSurveySubmission = respondToSurvey, - ) - - threadFailure?.let { failure -> - val context = LocalContext.current - - ChatTheme.Alert( - context.describe(failure), - onDismiss = resetCreateThreadState, - dismissLabel = context.getString(string.cancel), - ) - } - } - - /* handled by state monitor */ - ThreadSelected -> Ignored - } -} - +// Preview @Immutable internal data class PreviewAgent( override val id: Int, @@ -416,45 +345,34 @@ private data class PreviewThread( } private class PreviewModel( - private val mThreads: MutableStateFlow>, - private val mState: MutableStateFlow, - private val mCreateThreadFailure: MutableStateFlow, - private val survey: PreChatSurvey?, + private val threadsFlow: MutableStateFlow>, + private val stateFlow: MutableStateFlow, + private val createThreadFailureFlow: MutableStateFlow, ) { val threads: StateFlow> - get() = mThreads.asStateFlow() + get() = threadsFlow.asStateFlow() - val state: StateFlow - get() = mState.asStateFlow() + val state: StateFlow + get() = stateFlow.asStateFlow() val createThreadFailure: StateFlow - get () = mCreateThreadFailure.asStateFlow() + get () = createThreadFailureFlow.asStateFlow() constructor( threads: List, - state: ChatThreadsViewModel.State = Initial, - failure: Failure? = null, - survey: PreChatSurvey? + state: State = Initial, + failure: Failure? = null ) : this( MutableStateFlow(threads), MutableStateFlow(state), - MutableStateFlow(failure), - survey + MutableStateFlow(failure) ) @Suppress("UnusedPrivateMember") fun onThreadSelected(thread: Thread) = Unit - fun onCreateThread() { - if(survey != null) { - mState.value = ThreadPreChatSurveyRequired(survey) - } else { - createThread() - } - } - fun onArchiveThread(thread: Thread) { - mThreads.value = mThreads.value.map { + threadsFlow.value = threadsFlow.value.map { if (thread.id == it.id) { it.copy(chatThread = it.chatThread.copy(canAddMoreMessages = false)) } else { @@ -464,23 +382,23 @@ private class PreviewModel( } fun resetState() { - mState.value = Initial + stateFlow.value = Initial } fun respondToSurvey(responses: Sequence) { if(responses.count() != 2) { - mCreateThreadFailure.value = Failure.REASON_PRECHAT_SURVEY_REQUIRED + createThreadFailureFlow.value = Failure.REASON_PRECHAT_SURVEY_REQUIRED } else { createThread() } } fun resetCreateThreadState() { - mCreateThreadFailure.value = null + createThreadFailureFlow.value = null } private fun createThread() { - mThreads.value = mThreads.value + PreviewThread.nextThread() + threadsFlow.value += PreviewThread.nextThread() resetState() resetCreateThreadState() } @@ -488,14 +406,12 @@ private class PreviewModel( companion object { fun nextModel( threadCount: Int, - state: ChatThreadsViewModel.State = Initial, - failure: Failure? = null, - survey: PreChatSurvey? = null + state: State = Initial, + failure: Failure? = null ) = PreviewModel( threads = (0 until threadCount).map { PreviewThread.nextThread() }, state = state, - failure = failure, - survey = survey + failure = failure ) } } @@ -503,15 +419,14 @@ private class PreviewModel( @Preview @Composable private fun PreviewThreadList( - @PreviewParameter(PreviewModelProvider::class) viewModel: PreviewModel = PreviewModelProvider().values.drop(1).first() + @PreviewParameter(PreviewModelProvider::class) viewModel: PreviewModel = PreviewModelProvider().values.first() ) { ChatTheme { - ChatThreadsFragmentView( + MultiThreadContent( state = viewModel.state.collectAsState().value, threads = viewModel.threads.collectAsState().value, threadFailure = viewModel.createThreadFailure.collectAsState().value, onThreadSelected = viewModel::onThreadSelected, - onCreateThread = viewModel::onCreateThread, onArchiveThread = viewModel::onArchiveThread, resetState = viewModel::resetState, respondToSurvey = viewModel::respondToSurvey, @@ -545,9 +460,5 @@ private class PreviewModelProvider: PreviewParameterProvider { override val values = sequenceOf( PreviewModel.nextModel(4), - PreviewModel.nextModel( - 4, - survey = Survey, - ) ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/OfflineFragment.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/OfflineContentView.kt similarity index 50% rename from chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/OfflineFragment.kt rename to chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/OfflineContentView.kt index bf26fd32..13c82306 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/OfflineFragment.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/OfflineContentView.kt @@ -13,82 +13,65 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -package com.nice.cxonechat.ui.main +package com.nice.cxonechat.ui.composable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PortableWifiOff +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment import com.nice.cxonechat.ui.R.string import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.LocalChatTypography import com.nice.cxonechat.ui.composable.theme.Scaffold import com.nice.cxonechat.ui.composable.theme.TopBar -/** - * Fragment to be displayed when the chat services are offline. - */ -class OfflineFragment: Fragment() { - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = - ComposeView(requireContext()).apply { - setContent { - ContentView() - } +@Composable +internal fun OfflineContentView() { + Row(modifier = Modifier.fillMaxHeight(), verticalAlignment = Alignment.CenterVertically) { + Column { + Icon( + Icons.Default.PortableWifiOff, + stringResource(id = string.offline), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(60.dp) + ) + Text( + stringResource(string.offline_banner), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = LocalChatTypography.current.offlineBanner + ) + Text( + stringResource(string.offline_message), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = LocalChatTypography.current.offlineMessage + ) } + } } +@Preview @Composable -private fun ContentView() { +private fun PreviewOffline() { ChatTheme { ChatTheme.Scaffold( topBar = { ChatTheme.TopBar(title = stringResource(id = string.offline)) }, ) { - Row(modifier = Modifier.fillMaxHeight(), verticalAlignment = Alignment.CenterVertically) { - Column { - Icon( - Icons.Default.PortableWifiOff, - stringResource(id = string.offline), - modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(60.dp) - ) - Text( - stringResource(string.offline_banner), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = LocalChatTypography.current.offlineBanner - ) - Text( - stringResource(string.offline_message), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = LocalChatTypography.current.offlineMessage - ) - } - } + OfflineContentView() } } } - -@Preview -@Composable -private fun PreviewOffline() { - ContentView() -} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/SnackbarHostStateKtx.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/SnackbarHostStateKtx.kt new file mode 100644 index 00000000..fc521b38 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/SnackbarHostStateKtx.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarDuration.Indefinite +import androidx.compose.material3.SnackbarHostState +import com.nice.cxonechat.ui.composable.generic.showActionSnackbar + +/** + * Shows a snackbar with a message and an action that can be cancelled. + * + * @param message the message to show + * @param actionLabel the label of the action button + * @param onAction the action to perform when the action button is clicked + * @param duration the duration of the snackbar + */ +internal suspend fun SnackbarHostState.showCancellableSnackbar( + message: String, + actionLabel: String, + onAction: () -> Unit, + duration: SnackbarDuration = Indefinite, +) { + showActionSnackbar(message, actionLabel, duration, onAction) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/ThreadContentView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/ThreadContentView.kt new file mode 100644 index 00000000..c00888fd --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/ThreadContentView.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.nice.cxonechat.message.Attachment +import com.nice.cxonechat.ui.SelectAttachmentActivityLauncher +import com.nice.cxonechat.ui.composable.conversation.AudioRecordingUiState +import com.nice.cxonechat.ui.composable.conversation.ChatConversation +import com.nice.cxonechat.ui.composable.conversation.DialogView +import com.nice.cxonechat.ui.composable.conversation.model.ConversationUiState +import com.nice.cxonechat.ui.main.AudioRecordingViewModel +import com.nice.cxonechat.ui.main.ChatThreadViewModel +import com.nice.cxonechat.ui.main.ChatViewModel +import com.nice.cxonechat.ui.util.repeatOnOwnerLifecycle +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import java.util.UUID + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +internal fun ThreadContentView( + onAttachmentClicked: (Attachment) -> Unit, + onShare: (Collection) -> Unit, + closeChat: () -> Unit, + onDismissRecording: () -> Unit, + onTriggerRecording: suspend () -> Boolean, + chatThreadViewModel: ChatThreadViewModel, + chatViewModel: ChatViewModel, + audioViewModel: AudioRecordingViewModel, + snackbarHostState: SnackbarHostState, + activityLauncher: SelectAttachmentActivityLauncher, +) { + val owner = LocalLifecycleOwner.current + LaunchedEffect(chatThreadViewModel, owner) { + refreshThreadOnResume(chatThreadViewModel, owner) + } + ChatConversation( + conversationState = uiState(chatThreadViewModel, onShare, onAttachmentClicked), + audioRecordingState = audioState( + chatThreadViewModel, + audioViewModel, + onDismissRecording, + onTriggerRecording, + ), + onAttachmentTypeSelection = { + activityLauncher.getDocument(it.toTypedArray()) + }, + modifier = Modifier.semantics { + testTagsAsResourceId = true // Enabled for UI test automation + } + ) + DialogView( + onAttachmentClicked = onAttachmentClicked, + onShare = onShare, + closeChat = closeChat, + threadViewModel = chatThreadViewModel, + chatModel = chatViewModel, + ) + CustomPopUpView(snackbarHostState, chatThreadViewModel) +} + +private fun refreshThreadOnResume(chatThreadViewModel: ChatThreadViewModel, lifecycleOwner: LifecycleOwner) { + val threadIdFlow = chatThreadViewModel.chatThreadHandler.map { it.get().id } + val refreshFlow = lifecycleOwner.lifecycle.currentStateFlow + .combine(threadIdFlow) { state, id -> state to id } + .distinctUntilChanged { old, new -> old.first === new.first && old.second == new.second } + .filter { (state, id) -> state === State.RESUMED && id !== NIL_UUID } + .distinctUntilChanged() + lifecycleOwner.repeatOnOwnerLifecycle { + refreshFlow.collect { + chatThreadViewModel.refresh() + } + } +} + +private fun uiState( + chatThreadViewModel: ChatThreadViewModel, + onShare: (Collection) -> Unit, + onAttachmentClicked: (Attachment) -> Unit, +) = ConversationUiState( + sdkMessages = chatThreadViewModel.messages, + typingIndicator = chatThreadViewModel.agentState, + positionInQueue = chatThreadViewModel.positionInQueue, + sendMessage = chatThreadViewModel::sendMessage, + loadMore = chatThreadViewModel::loadMore, + canLoadMore = chatThreadViewModel.canLoadMore, + onStartTyping = { + chatThreadViewModel.reportThreadRead() + chatThreadViewModel.reportTypingStarted() + }, + onStopTyping = chatThreadViewModel::reportTypingEnd, + onAttachmentClicked = onAttachmentClicked, + onMoreClicked = chatThreadViewModel::selectAttachments, + onShare = onShare, + isArchived = chatThreadViewModel.isArchived, + isLiveChat = chatThreadViewModel.isLiveChat, +) + +private fun audioState( + chatThreadViewModel: ChatThreadViewModel, + audioViewModel: AudioRecordingViewModel, + onDismiss: () -> Unit, + onTriggerRecording: suspend () -> Boolean, +) = AudioRecordingUiState( + uriFlow = audioViewModel.recordedUriFlow, + isRecordingFlow = audioViewModel.recordingFlow, + onDismiss = onDismiss, + onApprove = chatThreadViewModel::sendAttachment, + onAudioRecordToggle = onTriggerRecording, +) + +private val NIL_UUID = UUID(0, 0) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/ThreadListContentView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/ThreadListContentView.kt new file mode 100644 index 00000000..3a4ee94f --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/ThreadListContentView.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.nice.cxonechat.ui.main.ChatThreadsViewModel + +@Composable +internal fun ThreadListContentView(chatThreadsViewModel: ChatThreadsViewModel, onThreadSelected: () -> Unit) { + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + LaunchedEffect(lifecycleState) { + if (lifecycleState === State.RESUMED) chatThreadsViewModel.refreshThreads() + } + val chatThreadsState by chatThreadsViewModel.state.collectAsState() + LaunchedEffect(chatThreadsState) { + if (chatThreadsState === ChatThreadsViewModel.State.ThreadSelected) { + onThreadSelected() + } + } + MultiThreadContent( + state = chatThreadsState, + threads = chatThreadsViewModel.threads.collectAsState().value, + threadFailure = chatThreadsViewModel.createThreadFailure.collectAsState().value, + onThreadSelected = chatThreadsViewModel::selectThread, + onArchiveThread = chatThreadsViewModel::archiveThread, + resetState = chatThreadsViewModel::resetState, + respondToSurvey = chatThreadsViewModel::respondToSurvey, + resetCreateThreadState = chatThreadsViewModel::resetCreateThreadState, + ) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentIcon.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentIcon.kt index b02fee4d..fd07d719 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentIcon.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentIcon.kt @@ -27,13 +27,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.outlined.FilePresent import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -128,7 +128,7 @@ private fun VideoIcon(attachment: Attachment) { ) } else { val context = LocalContext.current - val background = MaterialTheme.colors.surface.toArgb() + val background = MaterialTheme.colorScheme.surface.toArgb() val exoPlayer = remember { buildProgressivePlayerForUri(context, Uri.parse(attachment.url)).apply { diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentMessage.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentMessage.kt index 96e7f5ba..66e40b62 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentMessage.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentMessage.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -137,7 +137,7 @@ private fun MoreIcon( Box(contentAlignment = Alignment.Center) { Text( stringResource(string.extra_attachments_count, count), - style = ChatTheme.typography.subtitle1 + style = ChatTheme.typography.titleMedium ) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChatConversation.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChatConversation.kt index d603e89f..23f83b48 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChatConversation.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChatConversation.kt @@ -29,34 +29,28 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Scaffold -import androidx.compose.material.Text import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.filled.Archive -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nice.cxonechat.ui.R.drawable import com.nice.cxonechat.ui.R.string import com.nice.cxonechat.ui.composable.conversation.model.ConversationUiState import com.nice.cxonechat.ui.composable.conversation.model.PreviewMessageProvider import com.nice.cxonechat.ui.composable.conversation.model.Section import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.Scaffold -import com.nice.cxonechat.ui.composable.theme.TopBar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -68,10 +62,6 @@ import kotlinx.coroutines.launch * @param conversationState State of the conversation and means how to send new messages. * @param audioRecordingState State of the audio recording and means how to trigger it. * @param onAttachmentTypeSelection Action invoked when a user has selected what type of file they want to send as attachment. - * @param onEditThreadName Callback to trigger edit thread name dialog. - * @param onEditThreadValues Callback to trigger edit thread values dialog. - * @param onEndContact Callback to trigger the end of a contact. - * @param displayEndConversation Callback to trigger the end of contact dialog. * @param modifier Optional [Modifier] for [Scaffold] surrounding the conversation view. */ @Composable @@ -79,10 +69,6 @@ internal fun ChatConversation( conversationState: ConversationUiState, audioRecordingState: AudioRecordingUiState, onAttachmentTypeSelection: (mimeType: Collection) -> Unit, - onEditThreadName: () -> Unit, - onEditThreadValues: () -> Unit, - onEndContact: () -> Unit, - displayEndConversation: () -> Unit, modifier: Modifier = Modifier, ) { val scrollState = rememberLazyListState() @@ -97,34 +83,22 @@ internal fun ChatConversation( } } - ChatTheme.Scaffold( - topBar = { - ChatThreadTopBar( - conversationState = conversationState, - onEditThreadName = onEditThreadName, - onEditThreadValues = onEditThreadValues, - onEndContact = onEndContact, - displayEndConversation = displayEndConversation, - ) - } + Column( + modifier.fillMaxSize(), ) { - Column( - modifier.fillMaxSize(), - ) { - MessageListView( - messages, - conversation = conversationState, - scrollState = scrollState, - modifier = Modifier.weight(1f) - ) - UserInputView( - conversationState = conversationState, - scope = scope, - scrollState = scrollState, - audioRecordingState = audioRecordingState, - onAttachmentTypeSelection = onAttachmentTypeSelection, - ) - } + MessageListView( + messages, + conversation = conversationState, + scrollState = scrollState, + modifier = Modifier.weight(1f) + ) + UserInputView( + conversationState = conversationState, + scope = scope, + scrollState = scrollState, + audioRecordingState = audioRecordingState, + onAttachmentTypeSelection = onAttachmentTypeSelection, + ) } } @@ -178,56 +152,6 @@ private fun UserInputView( } } -@Composable -private fun ChatThreadTopBar( - conversationState: ConversationUiState, - onEditThreadName: () -> Unit, - onEditThreadValues: () -> Unit, - onEndContact: () -> Unit, - displayEndConversation: () -> Unit, -) { - ChatTheme.TopBar( - title = conversationState.threadName.collectAsState(null).value?.ifBlank { null } - ?: stringResource(id = string.thread_list_title), - actions = { - if (conversationState.isMultiThreaded) { - IconButton(onClick = onEditThreadName) { - Icon( - painter = painterResource(id = drawable.ic_baseline_chat_24), - contentDescription = stringResource(id = string.change_thread_name) - ) - } - } - if (conversationState.hasQuestions) { - IconButton(onClick = onEditThreadValues) { - Icon( - painter = painterResource(id = drawable.ic_baseline_edit), - contentDescription = stringResource(id = string.change_details_label) - ) - } - } - if (conversationState.isLiveChat) { - if (conversationState.isArchived.collectAsState().value) { - IconButton(onClick = displayEndConversation) { - Icon( - painter = rememberVectorPainter(image = Filled.MoreVert), - contentDescription = stringResource(id = string.livechat_conversation_options) - ) - } - } else { - IconButton(onClick = onEndContact) { - Icon( - painter = painterResource(id = drawable.ic_baseline_cancel_24), - tint = ChatTheme.colors.error, - contentDescription = stringResource(id = string.action_end_conversation) - ) - } - } - } - } - ) -} - @Composable internal fun MessageListView( messages: List
, @@ -300,10 +224,6 @@ private fun PreviewChatMessageInput() { conversationState = previewUiState(messages, positionInQueue = 4), audioRecordingState = previewAudioState(), onAttachmentTypeSelection = {}, - onEditThreadName = {}, - onEditThreadValues = {}, - onEndContact = {}, - displayEndConversation = {}, ) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChatThreadTopBar.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChatThreadTopBar.kt new file mode 100644 index 00000000..6f4bc5fc --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChatThreadTopBar.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable.conversation + +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.material.icons.Icons.Filled +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.composable.conversation.model.ConversationTopBarState +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.composable.theme.Scaffold +import com.nice.cxonechat.ui.composable.theme.TextField +import com.nice.cxonechat.ui.composable.theme.TopBar +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +internal fun ChatThreadTopBar( + conversationState: ConversationTopBarState, + onEditThreadName: () -> Unit, + onEditThreadValues: () -> Unit, + onEndContact: () -> Unit, + displayEndConversation: () -> Unit, +) { + ChatTheme.TopBar( + title = conversationState.threadName + .collectAsState(null) + .value + ?.ifBlank { null } + ?: stringResource(id = R.string.thread_list_title), + actions = { + if (conversationState.isMultiThreaded) { + IconButton(onClick = onEditThreadName) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_chat_24), + contentDescription = stringResource(id = R.string.change_thread_name) + ) + } + } + if (conversationState.hasQuestions) { + IconButton(onClick = onEditThreadValues) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_edit), + contentDescription = stringResource(id = R.string.change_details_label) + ) + } + } + if (conversationState.isLiveChat) { + if (conversationState.isArchived.collectAsState().value) { + IconButton(onClick = displayEndConversation) { + Icon( + painter = rememberVectorPainter(image = Filled.MoreVert), + contentDescription = stringResource(id = R.string.livechat_conversation_options) + ) + } + } else { + IconButton(onClick = onEndContact) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_cancel_24), + tint = ChatTheme.colorScheme.error, + contentDescription = stringResource(id = R.string.action_end_conversation) + ) + } + } + } + } + ) +} + +@Composable +@Preview +private fun PreviewChatThreadTopBar() { + val threadNameFlow: MutableStateFlow = remember { MutableStateFlow(null) } + val threadName by threadNameFlow.collectAsState() + val isArchivedFlow: MutableStateFlow = remember { MutableStateFlow(false) } + val isArchived by isArchivedFlow.collectAsState() + val isMultiThreaded = remember { mutableStateOf(true) } + val hasQuestions = remember { mutableStateOf(true) } + val isLiveChat = remember { mutableStateOf(true) } + ChatTheme { + ChatTheme.Scaffold( + topBar = { + ChatThreadTopBar( + conversationState = ConversationTopBarState( + threadName = threadNameFlow, + isMultiThreaded = isMultiThreaded.value, + hasQuestions = hasQuestions.value, + isLiveChat = isLiveChat.value, + isArchived = isArchivedFlow, + ), + onEditThreadName = {}, + onEditThreadValues = {}, + onEndContact = {}, + displayEndConversation = {} + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .then(Modifier.padding(8.dp)), + horizontalAlignment = Alignment.Start, + ) { + ChatTheme.TextField( + label = "Thread name", + value = threadName.orEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { name -> threadNameFlow.value = name } + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = isArchived, + onCheckedChange = { isArchivedFlow.value = it } + ) + Text("Archived") + } + PreviewSwitch(isMultiThreaded, "Multi-threaded") + PreviewSwitch(hasQuestions, "Has questions") + PreviewSwitch(isLiveChat, "Live chat") + } + } + } +} + +@Composable +private fun PreviewSwitch(checked: MutableState, label: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = checked.value, + onCheckedChange = { checked.value = it } + ) + Text(label) + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Chip.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Chip.kt index dc9e1b38..b309d445 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Chip.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Chip.kt @@ -22,12 +22,11 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.selectable -import androidx.compose.material.ContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.outlined.Downloading +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -43,7 +42,7 @@ import com.nice.cxonechat.ui.composable.conversation.model.PreviewMessageProvide import com.nice.cxonechat.ui.composable.generic.forwardingPainter import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.ChatTheme.chatShapes -import com.nice.cxonechat.ui.composable.theme.ChatTheme.colors +import com.nice.cxonechat.ui.composable.theme.ChatTheme.colorScheme import com.nice.cxonechat.ui.composable.theme.ChatTheme.space @Composable @@ -56,8 +55,8 @@ internal fun Chip( selected: Boolean = false, onSelected: () -> Unit, ) { - val color = colors.primary - val disabledColor = color.copy(alpha = ContentAlpha.disabled) + val color = colorScheme.primary + val disabledColor = colorScheme.onSurface.copy(alpha = 0.38f) Surface( color = if (enabled || selected) color else disabledColor, diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChipGroup.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChipGroup.kt index 4292f186..43f6c54c 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChipGroup.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ChipGroup.kt @@ -20,8 +20,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material.Button -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/CustomValuesDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/CustomValuesDialog.kt new file mode 100644 index 00000000..52c557dc --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/CustomValuesDialog.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable.conversation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nice.cxonechat.ui.EditCustomValuesDialog +import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.customvalues.mergeWithCustomField +import com.nice.cxonechat.ui.main.ChatThreadViewModel + +@Composable +internal fun CustomValuesDialog( + chatViewModel: ChatThreadViewModel, +) { + EditCustomValuesDialog( + title = stringResource(R.string.edit_custom_field_title), + fields = chatViewModel + .preChatSurvey + ?.fields + .orEmpty() + .mergeWithCustomField( + chatViewModel.customValues + ), + onCancel = chatViewModel::cancelEditingCustomValues, + onConfirm = chatViewModel::confirmEditingCustomValues + ) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/DayHeader.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/DayHeader.kt index 9d9888e2..df80cca9 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/DayHeader.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/DayHeader.kt @@ -19,8 +19,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding -import androidx.compose.material.Surface -import androidx.compose.material.Text +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 @@ -41,7 +41,7 @@ internal fun DayHeader(dayString: String, modifier: Modifier = Modifier) { text = dayString, modifier = Modifier.padding(horizontal = ChatTheme.space.large), style = ChatTheme.chatTypography.chatDayHeader, - color = ChatTheme.colors.onBackground + color = ChatTheme.colorScheme.onBackground ) DayHeaderLine() } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/DialogView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/DialogView.kt new file mode 100644 index 00000000..dcd73745 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/DialogView.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable.conversation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.res.stringResource +import com.nice.cxonechat.message.Attachment +import com.nice.cxonechat.ui.EditThreadNameDialog +import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.composable.generic.ImageViewerDialogCard +import com.nice.cxonechat.ui.composable.generic.VideoViewerDialogCard +import com.nice.cxonechat.ui.composable.theme.BusySpinner +import com.nice.cxonechat.ui.main.ChatThreadViewModel +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.AudioPlayer +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.CustomValues +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.EditThreadName +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.EndContact +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ErrorAttachmentNotSupported +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ErrorAttachmentTooLarge +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ErrorUnableToReadAttachment +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ImageViewer +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.None +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.SelectAttachments +import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.VideoPlayer +import com.nice.cxonechat.ui.main.ChatViewModel + +@Composable +internal fun DialogView( + onAttachmentClicked: (Attachment) -> Unit, + onShare: (Collection) -> Unit, + closeChat: () -> Unit, + threadViewModel: ChatThreadViewModel, + chatModel: ChatViewModel, +) { + when (val dialog = threadViewModel.dialogShown.collectAsState(None).value) { + None -> Unit + CustomValues -> CustomValuesDialog(threadViewModel) + EditThreadName -> EditThreadNameDialog( + threadName = threadViewModel.selectedThreadName.orEmpty(), + onCancel = threadViewModel::dismissDialog, + onAccept = threadViewModel::confirmEditThreadName + ) + + is AudioPlayer -> AudioPlayerDialog( + url = dialog.url, + title = dialog.title, + onCancel = threadViewModel::dismissDialog, + ) + + is SelectAttachments -> SelectAttachmentsDialog( + attachments = dialog.attachments, + title = dialog.title.orEmpty(), + onAttachmentTapped = onAttachmentClicked, + onCancel = threadViewModel::dismissDialog, + onShare = onShare, + ) + + is ImageViewer -> ImageViewerDialogCard( + image = dialog.image, + title = dialog.title, + onDismiss = threadViewModel::dismissDialog, + ) + + is VideoPlayer -> VideoViewerDialogCard( + uri = dialog.uri, + title = dialog.title, + onDismiss = threadViewModel::dismissDialog + ) + + ErrorAttachmentNotSupported -> ErrorDialog( + title = stringResource(id = R.string.attachment_upload_failure), + message = stringResource(id = R.string.attachment_not_supported), + onDismiss = threadViewModel::dismissDialog + ) + + ErrorAttachmentTooLarge -> ErrorDialog( + title = stringResource(id = R.string.attachment_upload_failure), + message = stringResource(id = R.string.attachment_too_large, threadViewModel.maxAttachmentSize), + onDismiss = threadViewModel::dismissDialog + ) + + ErrorUnableToReadAttachment -> ErrorDialog( + title = stringResource(id = R.string.attachment_upload_failure), + message = stringResource(id = R.string.attachment_read_error), + onDismiss = threadViewModel::dismissDialog + ) + + EndContact -> EndContactDialog(closeChat = closeChat, chatViewModel = threadViewModel, chatModel = chatModel) + } + + if (threadViewModel.preparingToShare.collectAsState().value) { + BusySpinner(message = stringResource(R.string.preparing)) + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/EndContactDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/EndContactDialog.kt new file mode 100644 index 00000000..bf792ed5 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/EndContactDialog.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable.conversation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.main.ChatThreadViewModel +import com.nice.cxonechat.ui.main.ChatViewModel +import com.nice.cxonechat.ui.model.EndConversationChoice.CLOSE_CHAT +import com.nice.cxonechat.ui.model.EndConversationChoice.NEW_CONVERSATION +import com.nice.cxonechat.ui.model.EndConversationChoice.SHOW_TRANSCRIPT + +@Composable +internal fun EndContactDialog( + closeChat: () -> Unit, + chatViewModel: ChatThreadViewModel, + chatModel: ChatViewModel, +) { + ChatTheme { + ChatTheme.EndConversationDialog( + assignedAgent = chatViewModel.chatMetadata.collectAsState(initial = null).value?.agent, + onDismiss = chatViewModel::dismissDialog, + onUserSelection = { + when (it) { + SHOW_TRANSCRIPT -> { + // no-op required + } + + NEW_CONVERSATION -> chatModel.refreshThreadState() + CLOSE_CHAT -> closeChat() + } + } + ) + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/EndConversationDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/EndConversationDialog.kt index 9fa6fe9d..dcde6ab5 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/EndConversationDialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/EndConversationDialog.kt @@ -23,14 +23,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card -import androidx.compose.material.Text import androidx.compose.material.icons.Icons.AutoMirrored import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.automirrored.filled.ArrowBackIos import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.outlined.ChatBubble +import androidx.compose.material3.Card +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -42,11 +42,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.nice.cxonechat.thread.Agent import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.composable.PreviewAgent import com.nice.cxonechat.ui.composable.generic.AgentAvatar import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.IconMultiButton import com.nice.cxonechat.ui.composable.theme.Space -import com.nice.cxonechat.ui.main.PreviewAgent import com.nice.cxonechat.ui.model.EndConversationChoice import com.nice.cxonechat.ui.model.EndConversationChoice.CLOSE_CHAT import com.nice.cxonechat.ui.model.EndConversationChoice.NEW_CONVERSATION @@ -108,7 +108,7 @@ internal fun ChatTheme.EndConversationDialog( private fun AgentName(agentName: String, space: Space) { Text( text = agentName, - style = ChatTheme.typography.h6, + style = ChatTheme.typography.titleLarge, modifier = Modifier.padding(bottom = space.large), ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ErrorDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ErrorDialog.kt new file mode 100644 index 00000000..05a40924 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ErrorDialog.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable.conversation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.composable.theme.Alert +import com.nice.cxonechat.ui.composable.theme.ChatTheme + +@Composable +internal fun ErrorDialog( + title: String, + message: String, + onDismiss: () -> Unit +) { + ChatTheme.Alert( + title = title, + message = message, + dismissLabel = stringResource(id = R.string.ok), + onDismiss = onDismiss + ) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ListPickerMessage.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ListPickerMessage.kt index 5f326395..b6cb12d6 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ListPickerMessage.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ListPickerMessage.kt @@ -18,7 +18,7 @@ package com.nice.cxonechat.ui.composable.conversation import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/LoadMore.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/LoadMore.kt index 75881a3f..5a654faf 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/LoadMore.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/LoadMore.kt @@ -23,9 +23,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -44,7 +44,7 @@ internal fun LazyItemScope.LoadMore(loadMore: () -> Unit) { Row( Modifier .fillMaxWidth() - .animateItemPlacement(), + .animateItem(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/MessageItem.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/MessageItem.kt index 34fc8b3b..25b74894 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/MessageItem.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/MessageItem.kt @@ -20,8 +20,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.material.Surface -import androidx.compose.material.Text +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 diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Messages.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Messages.kt index 8f0ef5d6..ca52f947 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Messages.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/Messages.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.ui.composable.conversation -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -41,7 +40,6 @@ import com.nice.cxonechat.ui.composable.theme.ChatTheme.space import com.nice.cxonechat.ui.util.isSameDay import java.util.Date -@OptIn(ExperimentalFoundationApi::class) @Composable internal fun ColumnScope.Messages( scrollState: LazyListState, @@ -86,13 +84,13 @@ internal fun ColumnScope.Messages( // Display "Today" over today's messages section.createdAt.isSameDay(Date()) -> item(contentType = DateHeader) { - DayHeader(dayString = stringResource(string.today), Modifier.animateItemPlacement()) + DayHeader(dayString = stringResource(string.today), Modifier.animateItem()) } // display appropriate date over other messages else -> item(contentType = DateHeader) { - DayHeader(dayString = section.createdAtDate, Modifier.animateItemPlacement()) + DayHeader(dayString = section.createdAtDate, Modifier.animateItem()) } } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PositionInQueue.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PositionInQueue.kt index 8160a300..307ba05b 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PositionInQueue.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PositionInQueue.kt @@ -22,7 +22,7 @@ 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.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PreviewUtils.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PreviewUtils.kt index 56118ad9..b398de0c 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PreviewUtils.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/PreviewUtils.kt @@ -18,7 +18,7 @@ package com.nice.cxonechat.ui.composable.conversation import android.net.Uri import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.material.Surface +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -126,13 +126,10 @@ internal fun PreviewMessageItemBase( ) internal fun previewUiState( messages: List = emptyList(), - isMultiThreaded: Boolean = true, - hasQuestions: Boolean = true, isArchived: Boolean = false, positionInQueue: Int? = null, isLiveChat: Boolean = true, ) = ConversationUiState( - threadName = flowOf("Preview Thread"), sdkMessages = MutableStateFlow(messages), typingIndicator = flowOf(true), positionInQueue = flowOf(positionInQueue), @@ -144,8 +141,6 @@ internal fun previewUiState( onAttachmentClicked = {}, onMoreClicked = { _, _ -> }, onShare = {}, - isMultiThreaded = isMultiThreaded, - hasQuestions = hasQuestions, isArchived = MutableStateFlow(isArchived), isLiveChat = isLiveChat, ) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/QuickReplyMessage.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/QuickReplyMessage.kt index 6fd10a7c..8c9e15f8 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/QuickReplyMessage.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/QuickReplyMessage.kt @@ -18,7 +18,7 @@ package com.nice.cxonechat.ui.composable.conversation import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/RichLinkMessage.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/RichLinkMessage.kt index 6708d566..11e19d93 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/RichLinkMessage.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/RichLinkMessage.kt @@ -17,7 +17,7 @@ package com.nice.cxonechat.ui.composable.conversation import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/SelectAttachmentsDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/SelectAttachmentsDialog.kt index ea3d5b97..e5ca3c7f 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/SelectAttachmentsDialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/SelectAttachmentsDialog.kt @@ -25,17 +25,18 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.filled.Deselect import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember @@ -159,6 +160,7 @@ private fun TopBar( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun BottomBar( selection: Collection, @@ -174,11 +176,11 @@ private fun BottomBar( ) { Row { TextButton(onClick = onSelectAll) { - Text(stringResource(string.select_all), color = ChatTheme.colors.onPrimary) + Text(stringResource(string.select_all), color = ChatTheme.colorScheme.onPrimary) } TextButton(onClick = onSelectNone) { - Text(stringResource(string.select_none), color = ChatTheme.colors.onPrimary) + Text(stringResource(string.select_none), color = ChatTheme.colorScheme.onPrimary) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/UserInput.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/UserInput.kt index f377af87..16368628 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/UserInput.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/UserInput.kt @@ -33,21 +33,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconToggleButton -import androidx.compose.material.LocalContentColor -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.material.icons.Icons.AutoMirrored import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.MicNone +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -88,7 +88,6 @@ import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.ChatTheme.space import com.nice.cxonechat.ui.composable.theme.SelectableIconButton import com.nice.cxonechat.ui.composable.theme.SmallSpacer -import com.nice.cxonechat.ui.composable.theme.contentColorFor import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -238,7 +237,7 @@ private fun Header() { .height(24.dp) .padding(vertical = space.medium, horizontal = space.large) ) { - Divider( + HorizontalDivider( modifier = Modifier .weight(1f) .align(Alignment.CenterVertically) @@ -296,7 +295,7 @@ private fun RowScope.UserInputText( textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current) ) - val disableContentColor = ChatTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + val disableContentColor = ChatTheme.colorScheme.onSurface.copy(alpha = 0.38f) if (textFieldValue.text.isEmpty() && !focusState) { val hint = if (isAudioRecording) { stringResource(string.recording_audio_hint) @@ -308,7 +307,7 @@ private fun RowScope.UserInputText( .align(Alignment.CenterStart) .padding(horizontal = 16.dp), text = hint, - style = ChatTheme.typography.body1.copy(color = disableContentColor) + style = ChatTheme.typography.bodyLarge.copy(color = disableContentColor) ) } } @@ -341,7 +340,7 @@ private fun UserInputSelector( val border = if (!sendMessageEnabled) { BorderStroke( width = 1.dp, - color = ChatTheme.colors.onSurface.copy(alpha = 0.3f) + color = ChatTheme.colorScheme.onSurface.copy(alpha = 0.3f) ) } else { null @@ -407,7 +406,7 @@ private fun ChatTheme.InputSelectorToggleButton( checked: Boolean, onToggle: (Boolean) -> Unit, ) { - val selectedColor = if (checked) colors.secondaryVariant else colors.secondary + val selectedColor = if (checked) colorScheme.primary else colorScheme.secondary val backgroundModifier = Modifier.background( color = selectedColor, shape = RoundedCornerShape(8.dp) @@ -415,11 +414,16 @@ private fun ChatTheme.InputSelectorToggleButton( IconToggleButton( checked = checked, onCheckedChange = onToggle, + colors = IconButtonDefaults.iconToggleButtonColors( + containerColor = colorScheme.secondary, + contentColor = colorScheme.onSecondary, + checkedContainerColor = colorScheme.primary, + checkedContentColor = colorScheme.onPrimary, + ), modifier = Modifier.then(backgroundModifier) ) { Icon( icon, - tint = contentColorFor(backgroundColor = selectedColor), modifier = Modifier.padding(4.dp), contentDescription = description ) @@ -436,7 +440,7 @@ private fun SelectorExpanded( ) { if (currentSelector == None) return - Surface(elevation = 8.dp) { + Surface(shadowElevation = 8.dp, tonalElevation = 8.dp) { when (currentSelector) { Attachment -> AttachmentPickerDialog(onCloseRequested, onAttachmentTypeSelection) Audio -> AudioRecordingDialog( diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ConversationTopBarState.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ConversationTopBarState.kt new file mode 100644 index 00000000..78a857cf --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ConversationTopBarState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable.conversation.model + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +@Stable +internal data class ConversationTopBarState( + val threadName: Flow, + val isMultiThreaded: Boolean, + val hasQuestions: Boolean, + val isLiveChat: Boolean, + val isArchived: StateFlow, +) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ConversationUiState.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ConversationUiState.kt index 3a8138ed..a64ee7fa 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ConversationUiState.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ConversationUiState.kt @@ -37,7 +37,6 @@ import com.nice.cxonechat.message.Message as SdkMessage * Captures the state of active conversation and also handling of various actions possible within * the conversation. * - * @property threadName Flow of names for active conversation - usually bound to the conversation thread. * @param sdkMessages Flow of messages for the active conversation, it is expected that the flow will be updated if * [sendMessage] is invoked. * @property typingIndicator Flow indicating that the agent handling the conversation is typing. @@ -51,19 +50,16 @@ import com.nice.cxonechat.message.Message as SdkMessage * @property onAttachmentClicked An action which handles when users clicks on an Attachment * @property onMoreClicked An action to take when the more button is clicked in an attachment preview. * @property onShare Action to take when share is selected via long press or attachment selection dialog. - * @param backgroundDispatcher Optional dispatcher used for mapping of incoming messages off the main thread, - * intended for testing. - * @property isMultiThreaded true iff the channel is configured for multiple threads. - * @property hasQuestions true iff there is a prechat questionnaire for the channel. * @property isArchived Flow indicating if the thread was archived. * @property isLiveChat true iff the channel is configured as live chat. + * @param backgroundDispatcher Optional dispatcher used for mapping of incoming messages off the main thread, + * intended for testing. */ @Suppress( "LongParameterList", // POJO class ) @Stable internal data class ConversationUiState( - internal val threadName: Flow, private val sdkMessages: Flow>, internal val typingIndicator: Flow, internal val positionInQueue: Flow, @@ -75,11 +71,9 @@ internal data class ConversationUiState( internal val onAttachmentClicked: (Attachment) -> Unit, internal val onMoreClicked: (List, String) -> Unit, internal val onShare: (Collection) -> Unit, - private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default, - internal val isMultiThreaded: Boolean, - internal val hasQuestions: Boolean, internal val isArchived: StateFlow, internal val isLiveChat: Boolean, + private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default, ) { @Stable internal fun messages(context: Context): Flow> = sdkMessages diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AgentAvatar.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AgentAvatar.kt index 25445baf..9e70273c 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AgentAvatar.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AgentAvatar.kt @@ -33,7 +33,7 @@ import com.nice.cxonechat.ui.composable.theme.ChatTheme internal fun AgentAvatar(url: String, modifier: Modifier = Modifier) { val placeholder = forwardingPainter( painter = rememberVectorPainter(image = Outlined.AccountCircle), - colorFilter = ColorFilter.tint(ChatTheme.colors.onBackground) + colorFilter = ColorFilter.tint(ChatTheme.colorScheme.onBackground) ) AsyncImage( diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AsyncImagePainters.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AsyncImagePainters.kt index ad1c93f6..84a596ac 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AsyncImagePainters.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AsyncImagePainters.kt @@ -15,11 +15,11 @@ package com.nice.cxonechat.ui.composable.generic -import androidx.compose.material.LocalContentColor import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Downloading import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AttachmentPickerDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AttachmentPickerDialog.kt index fc91c930..b491949f 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AttachmentPickerDialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AttachmentPickerDialog.kt @@ -24,10 +24,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll -import androidx.compose.material.AlertDialog -import androidx.compose.material.Button -import androidx.compose.material.RadioButton -import androidx.compose.material.Text +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -115,7 +115,7 @@ private fun AttachmentPickerDialogContent( ) Text( text = label, - style = ChatTheme.typography.body1.merge(), + style = ChatTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = ChatTheme.space.large) ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioPlayer.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioPlayer.kt index 5fc791f1..4112b99b 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioPlayer.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioPlayer.kt @@ -29,14 +29,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.Surface import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.outlined.FastForward import androidx.compose.material.icons.outlined.FastRewind import androidx.compose.material.icons.outlined.PlayCircleOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -109,7 +109,7 @@ private fun playerFactory(context: Context, exoPlayer: ExoPlayer) = private fun InspectionPlaceholder(modifier: Modifier = Modifier) { Surface( modifier = modifier, - border = BorderStroke(1.dp, ChatTheme.colors.onSurface.copy(alpha = 0.3f)) + border = BorderStroke(1.dp, ChatTheme.colorScheme.onSurface.copy(alpha = 0.3f)) ) { Column( modifier = Modifier @@ -117,8 +117,8 @@ private fun InspectionPlaceholder(modifier: Modifier = Modifier) { .height(80.dp) ) { LinearProgressIndicator( - progress = 0.5f, - modifier = Modifier.fillMaxWidth() + progress = { 0.5f }, + modifier = Modifier.fillMaxWidth(), ) Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxSize()) { IconButton(onClick = {}, modifier = Modifier.fillMaxHeight()) { diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioRecordingDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioRecordingDialog.kt index 709db07c..e03a771d 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioRecordingDialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AudioRecordingDialog.kt @@ -23,15 +23,16 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.material.AlertDialog -import androidx.compose.material.Button -import androidx.compose.material.Icon -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Text +import androidx.compose.material.icons.Icons.AutoMirrored import androidx.compose.material.icons.Icons.Outlined +import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.outlined.Refresh -import androidx.compose.material.icons.outlined.Send import androidx.compose.material.icons.outlined.Stop +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf @@ -102,7 +103,7 @@ private fun DialogContent( private fun ConfirmButton(uri: Uri, onApprove: (Uri) -> Unit) { OutlinedButton(onClick = { onApprove(uri) }) { Row { - Icon(Outlined.Send, stringResource(string.send_audio_message_content_description)) + Icon(AutoMirrored.Outlined.Send, stringResource(string.send_audio_message_content_description)) SmallSpacer() Text(stringResource(string.text_send)) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AutoLinkedText.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AutoLinkedText.kt index 4eab1e3c..8bf1c64a 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AutoLinkedText.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/AutoLinkedText.kt @@ -19,8 +19,9 @@ import android.text.Spannable import android.text.Spannable.Factory import android.text.style.URLSpan import android.text.util.Linkify -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation @@ -30,6 +31,8 @@ import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.Preview import androidx.core.text.util.LinkifyCompat import com.nice.cxonechat.ui.composable.theme.ChatTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable internal fun AutoLinkedText( @@ -37,22 +40,28 @@ internal fun AutoLinkedText( modifier: Modifier = Modifier, style: TextStyle = TextStyle.Default, ) { + val linked = produceState(AnnotatedString(text)) { + value = autoLinkedText(text) + } Text( - text = autoLinkedText(text), + text = linked.value, modifier = modifier, style = style, ) } -internal fun autoLinkedText( +internal suspend fun autoLinkedText( text: String, -): AnnotatedString = spannableToAnnotated( - Factory.getInstance() - .newSpannable(text) - .also { - LinkifyCompat.addLinks(it, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS) - } -) +): AnnotatedString = Factory.getInstance() + .newSpannable(text) + .let { linkify(it) } + .let(::spannableToAnnotated) + +private suspend fun linkify(spannable: Spannable): Spannable = withContext(Dispatchers.IO) { + // Some devices access system files defining possible phone number formats, which triggers BlogGuard + LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS) + spannable +} private fun spannableToAnnotated(spannable: Spannable): AnnotatedString = buildAnnotatedString { var lastEnd = 0 diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/CardDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/CardDialog.kt index 20da760f..2330bdcf 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/CardDialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/CardDialog.kt @@ -17,9 +17,9 @@ package com.nice.cxonechat.ui.composable.generic import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Card -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Card +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 diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Carousel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Carousel.kt index 83c2725d..071b1d30 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Carousel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Carousel.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.android.material.math.MathUtils.lerp import com.nice.cxonechat.ui.composable.theme.ChatTheme -import com.nice.cxonechat.ui.composable.theme.ChatTheme.colors +import com.nice.cxonechat.ui.composable.theme.ChatTheme.colorScheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.absoluteValue @@ -132,8 +132,8 @@ private fun DotIndicators( pageCount: Int, pagerState: PagerState, modifier: Modifier = Modifier, - selectedColor: Color = colors.primary, - unselectedColor: Color = colors.secondary, + selectedColor: Color = colorScheme.primary, + unselectedColor: Color = colorScheme.secondary, dotSize: Dp = 6.dp, dotSpacing: Dp = 3.dp, ) { @@ -155,8 +155,8 @@ private fun DotIndicators( @Composable private fun DotIndicator( selected: Boolean, - selectedColor: Color = colors.primary, - unselectedColor: Color = colors.secondary, + selectedColor: Color = colorScheme.primary, + unselectedColor: Color = colorScheme.secondary, dotSize: Dp = 6.dp, ) { val color = if (selected) { diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/DropdownField.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/DropdownField.kt index 45c4086c..be0d1a63 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/DropdownField.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/DropdownField.kt @@ -23,17 +23,16 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.Divider -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +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 @@ -98,10 +97,11 @@ internal fun DropdownField( modifier = Modifier.align(Alignment.BottomStart) ) { if (placeholder.isNotBlank()) { - DropdownMenuItem(onClick = { }) { - Text(placeholder) - } - Divider() + DropdownMenuItem( + onClick = { }, + text = { Text(placeholder) } + ) + HorizontalDivider() } options.forEach { item -> DropdownMenuItem( @@ -109,14 +109,17 @@ internal fun DropdownField( expanded = false onSelect(item.value) }, - ) { - when(value) { - item.value -> SelectedIcon() - "" -> Unit - else -> Spacer(Modifier.width(16.dp)) + leadingIcon = { + when(value) { + item.value -> SelectedIcon() + "" -> Unit + else -> Spacer(Modifier.width(16.dp)) + } + }, + text = { + Text(item.label) } - Text(item.label) - } + ) } } } @@ -131,9 +134,9 @@ private fun ErrorLabel( Text( text = label, color = if (isError) { - MaterialTheme.colors.error + MaterialTheme.colorScheme.error } else { - LocalContentColor.current.copy(LocalContentAlpha.current) + LocalContentColor.current.copy(alpha = 0.38f) } ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExpandableIcon.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExpandableIcon.kt index edd32b3a..8d0f02eb 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExpandableIcon.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExpandableIcon.kt @@ -17,9 +17,9 @@ package com.nice.cxonechat.ui.composable.generic import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/FullScreenView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/FullScreenView.kt index 735f1fe2..0fb30ab1 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/FullScreenView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/FullScreenView.kt @@ -24,13 +24,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -145,7 +145,7 @@ private fun FullscreenButton( modifier = modifier .background( shape = CircleShape, - color = ChatTheme.colors.surface.copy(alpha = 0.6f) + color = ChatTheme.colorScheme.surface.copy(alpha = 0.6f) ), onClick = { onClick(!isFullScreenDefault) }, interactionSource = interactionSource, diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageCarousel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageCarousel.kt index 2b483d99..04cfd38b 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageCarousel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageCarousel.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.Surface +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -88,8 +88,8 @@ private fun PreviewImageCarousel() { placeholder = BrushPainter( Brush.linearGradient( listOf( - Color(color = ChatTheme.colors.surface.toArgb()), - Color(color = ChatTheme.colors.primary.copy(alpha = 0.2f).toArgb()), + Color(color = ChatTheme.colorScheme.surface.toArgb()), + Color(color = ChatTheme.colorScheme.primary.copy(alpha = 0.2f).toArgb()), ) ) ) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageViewerDialogCard.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageViewerDialogCard.kt index 3df79db0..c6fc92f8 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageViewerDialogCard.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageViewerDialogCard.kt @@ -19,7 +19,7 @@ import android.widget.Toast import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.Surface +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SimpleAlertDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SimpleAlertDialog.kt index e6a554e6..b55fdd22 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SimpleAlertDialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SimpleAlertDialog.kt @@ -15,10 +15,10 @@ package com.nice.cxonechat.ui.composable.generic -import androidx.compose.material.AlertDialog -import androidx.compose.material.Button -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SnackbarHostState.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SnackbarHostState.kt new file mode 100644 index 00000000..e4e8d9fa --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SnackbarHostState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.composable.generic + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarDuration.Indefinite +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SnackbarResult.ActionPerformed +import androidx.compose.material3.SnackbarResult.Dismissed + +internal suspend fun SnackbarHostState.showActionSnackbar( + message: String, + actionLabel: String, + duration: SnackbarDuration = Indefinite, + onAction: () -> Unit, + onDismiss: () -> Unit = {}, +) { + showSnackbar( + message = message, + actionLabel = actionLabel, + duration = duration, + ).also { result: SnackbarResult -> + when (result) { + ActionPerformed -> onAction() + Dismissed -> onDismiss() + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SpannableTextView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SpannableTextView.kt index 253e3482..fd0520ef 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SpannableTextView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/SpannableTextView.kt @@ -38,7 +38,7 @@ private const val SPACING_FIX = 3f internal fun SpannableTextView( spannable: Spannable, modifier: Modifier = Modifier, - textStyle: TextStyle = ChatTheme.typography.body1, + textStyle: TextStyle = ChatTheme.typography.bodyLarge, ) { AndroidView( modifier = modifier, @@ -70,7 +70,7 @@ internal fun SpannableTextView( internal fun HtmlText( html: String, modifier: Modifier = Modifier, - textStyle: TextStyle = ChatTheme.typography.body1 + textStyle: TextStyle = ChatTheme.typography.bodyLarge ) { SpannableTextView( spannable = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) as Spannable, diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Text.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Text.kt index 35ed0819..d647260e 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Text.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/Text.kt @@ -18,7 +18,7 @@ package com.nice.cxonechat.ui.composable.generic import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/TreeField.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/TreeField.kt index 1f0968e5..82b9aecc 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/TreeField.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/TreeField.kt @@ -23,10 +23,10 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoPlayer.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoPlayer.kt index b22ff568..af5ae931 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoPlayer.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoPlayer.kt @@ -23,12 +23,12 @@ import androidx.annotation.OptIn import androidx.compose.foundation.Image import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentColor import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons.Outlined import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.outlined.VideoFile +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoViewerDialogCard.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoViewerDialogCard.kt index dd6db94c..30db81aa 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoViewerDialogCard.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoViewerDialogCard.kt @@ -16,7 +16,7 @@ package com.nice.cxonechat.ui.composable.generic import android.net.Uri -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Alert.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Alert.kt index 7793e348..f3139257 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Alert.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Alert.kt @@ -16,7 +16,7 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -41,7 +41,7 @@ internal fun ChatTheme.Alert( OutlinedButton(text = dismissLabel, onClick = onDismiss) } ) { - Text(message, modifier = Modifier.fillMaxWidth(), style = typography.body2) + Text(message, modifier = Modifier.fillMaxWidth(), style = chatTypography.dialogBody) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/BusySpinner.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/BusySpinner.kt index 33b6ad1d..11b162eb 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/BusySpinner.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/BusySpinner.kt @@ -19,10 +19,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Card -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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 @@ -41,7 +42,11 @@ fun BusySpinner(message: String, onCancel: (() -> Unit)? = null) { Dialog( onDismissRequest = { }, ) { - Card(backgroundColor = MaterialTheme.colors.background.copy(alpha = 0.75f)) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.75f) + ) + ) { Column( modifier = Modifier.padding(space.defaultPadding), horizontalAlignment = Alignment.CenterHorizontally, @@ -49,7 +54,7 @@ fun BusySpinner(message: String, onCancel: (() -> Unit)? = null) { ) { CircularProgressIndicator( modifier = Modifier.size(32.dp), - color = MaterialTheme.colors.primary, + color = MaterialTheme.colorScheme.primary, ) Text(message) onCancel?.let { @@ -63,7 +68,7 @@ fun BusySpinner(message: String, onCancel: (() -> Unit)? = null) { } } -@Preview(showBackground = true, showSystemUi = true) +@Preview @Composable private fun BusySpinnerPreview() { ChatTheme { diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Buttons.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Buttons.kt index 07d9545a..98f3b8b0 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Buttons.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Buttons.kt @@ -23,15 +23,15 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ButtonColors -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons.Outlined -import androidx.compose.material.icons.outlined.Send +import androidx.compose.material.icons.Icons.AutoMirrored.Outlined +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,9 +43,9 @@ import androidx.compose.ui.unit.dp @Composable internal fun ChatTheme.buttonColors(isDefault: Boolean): ButtonColors { - val background = if (isDefault) colors.primary else colors.background + val background = if (isDefault) colorScheme.primary else colorScheme.background return ButtonDefaults.buttonColors( - backgroundColor = background, + containerColor = background, contentColor = contentColorFor(background) ) } @@ -97,7 +97,7 @@ internal fun ChatTheme.SelectableIconButton( backgroundModifier: Modifier = Modifier, onClick: () -> Unit, ) { - val selectedColor = if (selected) colors.secondaryVariant else colors.secondary + val selectedColor = if (selected) colorScheme.tertiary else colorScheme.secondary val coloredBackgroundModifier = backgroundModifier.background( color = selectedColor, shape = RoundedCornerShape(8.dp) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatColors.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatColors.kt index 29346f1c..af3ae758 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatColors.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatColors.kt @@ -23,8 +23,8 @@ import androidx.compose.foundation.layout.IntrinsicSize.Min import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatShapes.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatShapes.kt index 7a216e8c..401bffa9 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatShapes.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatShapes.kt @@ -20,8 +20,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf @@ -95,8 +95,8 @@ private fun PreviewShapes() { ) { shapes.forEach { (label, shape) -> Surface( - color = ChatTheme.colors.primary, - contentColor = ChatTheme.colors.onPrimary, + color = ChatTheme.colorScheme.primary, + contentColor = ChatTheme.colorScheme.onPrimary, shape = shape, ) { Text(text = label, modifier = Modifier.padding(24.dp)) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTheme.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTheme.kt index 8144ec72..85d9df46 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTheme.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTheme.kt @@ -16,12 +16,12 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes -import androidx.compose.material.Typography -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable @@ -34,7 +34,7 @@ internal fun ChatTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Com ChatThemeDetails.lightColors } val colors = if (darkTheme) { - darkColors( + darkColorScheme( primary = theme.primary, onPrimary = theme.onPrimary, background = theme.background, @@ -43,10 +43,9 @@ internal fun ChatTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Com onSurface = theme.onBackground, secondary = theme.accent, onSecondary = theme.onAccent, - secondaryVariant = theme.accent.rippleVariant(), ) } else { - lightColors( + lightColorScheme( primary = theme.primary, onPrimary = theme.onPrimary, background = theme.background, @@ -55,7 +54,6 @@ internal fun ChatTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Com onSurface = theme.onBackground, secondary = theme.accent, onSecondary = theme.onAccent, - secondaryVariant = theme.accent.rippleVariant(), ) } val chatColors = ChatColors(theme) @@ -68,7 +66,7 @@ internal fun ChatTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Com LocalImages provides images, ) { MaterialTheme( - colors = colors, + colorScheme = colors, typography = Typography, shapes = Shapes, content = content @@ -80,10 +78,10 @@ internal object ChatTheme { /** * Retrieves the current [DefaultColors] at the call site's position in the hierarchy. */ - val colors: Colors + val colorScheme: ColorScheme @Composable @ReadOnlyComposable - get() = MaterialTheme.colors + get() = MaterialTheme.colorScheme /** * Retrieves the current [Typography] at the call site's position in the hierarchy. diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatThemeDetails.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatThemeDetails.kt index 74faef35..431cd31c 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatThemeDetails.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatThemeDetails.kt @@ -15,12 +15,9 @@ package com.nice.cxonechat.ui.composable.theme -import com.nice.cxonechat.Public - /** * Color values for the chat SDK. */ -@Public object ChatThemeDetails { /** Colors for dark mode. */ var darkColors = DefaultColors.dark diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTypography.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTypography.kt index c5b0c30f..e2f6b8df 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTypography.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ChatTypography.kt @@ -23,26 +23,28 @@ import androidx.compose.ui.text.style.TextAlign @Immutable internal data class ChatTypography( - val threadListName: TextStyle = Typography.body1.copy(fontWeight = FontWeight.Bold), - val threadListLastMessage: TextStyle = Typography.body2, - val chatAgentName: TextStyle = Typography.subtitle1, - val chatMessage: TextStyle = Typography.body1, - val chatStatus: TextStyle = Typography.caption, - val chatAttachmentCaption: TextStyle = Typography.caption, - val chatAttachmentMessage: TextStyle = Typography.subtitle2, - val chatDayHeader: TextStyle = Typography.subtitle1, - val chatLoadMoreCaption: TextStyle = Typography.caption, - val dialogTitle: TextStyle = Typography.h6, - val chatCardTitle: TextStyle = Typography.subtitle1.copy( + val threadListName: TextStyle = Typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + val threadListLastMessage: TextStyle = Typography.bodyMedium, + val chatAgentName: TextStyle = Typography.titleMedium, + val chatMessage: TextStyle = Typography.bodyLarge, + val chatStatus: TextStyle = Typography.bodySmall, + val chatAttachmentCaption: TextStyle = Typography.bodySmall, + val chatAttachmentMessage: TextStyle = Typography.titleSmall, + val chatDayHeader: TextStyle = Typography.titleMedium, + val chatLoadMoreCaption: TextStyle = Typography.bodySmall, + val dialogTitle: TextStyle = Typography.titleLarge, + val dialogBody: TextStyle = Typography.bodyMedium, + val chatCardTitle: TextStyle = Typography.titleMedium.copy( fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ), - val chatCardSubtitle: TextStyle = Typography.subtitle1.copy( + val chatCardSubtitle: TextStyle = Typography.titleMedium.copy( fontWeight = FontWeight.Normal, textAlign = TextAlign.Center ), - val offlineBanner: TextStyle = Typography.h6, - val offlineMessage: TextStyle = Typography.body1, + val offlineBanner: TextStyle = Typography.titleLarge, + val offlineMessage: TextStyle = Typography.bodyLarge, + val surveyListItem: TextStyle = Typography.bodySmall, ) internal val LocalChatTypography = staticCompositionLocalOf { diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Colors.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Colors.kt index 359f568b..e8e1364c 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Colors.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Colors.kt @@ -15,14 +15,11 @@ package com.nice.cxonechat.ui.composable.theme -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.LocalContentColor -import androidx.compose.material.LocalRippleConfiguration -import androidx.compose.material.contentColorFor +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.takeOrElse /** @@ -32,21 +29,8 @@ import androidx.compose.ui.graphics.takeOrElse @Composable @ReadOnlyComposable fun contentColorFor(backgroundColor: Color): Color = when (backgroundColor) { - Color.Transparent -> ChatTheme.colors.primary + Color.Transparent -> ChatTheme.colorScheme.primary ChatTheme.chatColors.agent.background -> ChatTheme.chatColors.agent.foreground ChatTheme.chatColors.customer.background -> ChatTheme.chatColors.customer.foreground - else -> ChatTheme.colors.contentColorFor(backgroundColor) + else -> ChatTheme.colorScheme.contentColorFor(backgroundColor) }.takeOrElse { LocalContentColor.current } - -/** - * Applies [lerp] between this color and [androidx.compose.material.ripple.RippleTheme.defaultColor] and - * [androidx.compose.material.ripple.RippleTheme.rippleAlpha], - * [androidx.compose.material.ripple.RippleAlpha.pressedAlpha] as a fraction. - */ -@OptIn(ExperimentalMaterialApi::class) -@Composable -internal fun Color.rippleVariant(): Color = lerp( - start = this, - stop = LocalRippleConfiguration.current?.color ?: Color.Unspecified, - fraction = LocalRippleConfiguration.current?.rippleAlpha?.pressedAlpha ?: 0.1f, -) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Dialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Dialog.kt index 62e22e79..febd20e3 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Dialog.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Dialog.kt @@ -17,8 +17,8 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material.AlertDialog -import androidx.compose.material.Text +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -43,7 +43,7 @@ internal fun ChatTheme.Dialog( Text( it, modifier = Modifier.padding(bottom = space.large), - style = typography.h6 + style = chatTypography.dialogTitle ) } content() diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ErrorLabel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ErrorLabel.kt index 8f5764fa..b173861e 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ErrorLabel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ErrorLabel.kt @@ -16,7 +16,7 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.foundation.background -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -28,9 +28,9 @@ internal fun ChatTheme.ErrorLabel(label: String?, error: String?) { error != null -> Text( label?.let { stringResource(string.error_validation_label, it, error) } ?: error, - color = colors.error + color = colorScheme.error ) label != null -> - Text(label, Modifier.background(colors.background)) + Text(label, Modifier.background(colorScheme.background)) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/FieldLabelDecoration.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/FieldLabelDecoration.kt index 52d4c3f6..e285ce80 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/FieldLabelDecoration.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/FieldLabelDecoration.kt @@ -17,15 +17,14 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource 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.shape.RoundedCornerShape -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -35,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +// TODO replace usage of this extension function with supporting text usage @Composable internal fun ChatTheme.FieldLabelDecoration( label: String?, @@ -42,10 +42,8 @@ internal fun ChatTheme.FieldLabelDecoration( isError: Boolean = false, content: @Composable () -> Unit ) { - val labelColor = TextFieldDefaults - .textFieldColors() - .labelColor(enabled = true, error = isError, interactionSource = remember(::MutableInteractionSource)) - .value + val colors = TextFieldDefaults.colors() + val labelColor = if (isError) colors.errorLabelColor else colors.unfocusedLabelColor Box(modifier = modifier) { Box( @@ -62,13 +60,13 @@ internal fun ChatTheme.FieldLabelDecoration( Row( Modifier .padding(start = space.large - 1.dp) - .background(colors.background) + .background(colorScheme.background) ) { Text( label, modifier = Modifier.padding(start = 1.dp, end = 1.dp), color = labelColor, - style = typography.caption + style = chatTypography.surveyListItem ) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/IconMutliToggleButton.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/IconMutliToggleButton.kt index fd6557d7..511b4ba9 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/IconMutliToggleButton.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/IconMutliToggleButton.kt @@ -25,12 +25,12 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.material.icons.Icons.AutoMirrored.Outlined import androidx.compose.material.icons.automirrored.outlined.ArrowBackIos +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,7 +62,7 @@ internal fun ChatTheme.IconMultiButton( val buttonIterable = remember(buttons::toList) buttonIterable.forEachIndexed { index, entry -> if (index != 0) { - Divider( + HorizontalDivider( color = Color.LightGray, modifier = Modifier .fillMaxWidth() diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Images.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Images.kt index 065ae875..441625b8 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Images.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Images.kt @@ -17,13 +17,11 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf -import com.nice.cxonechat.Public import com.nice.cxonechat.ui.R /** * Set of images used for decoration and branding. */ -@Public @Immutable interface Images { @@ -40,7 +38,6 @@ interface Images { * @param logo Branding image which will be used as logo, see [Images.logo]. * It can be any resource supported by Coil to load an image. */ - @Public @JvmStatic @JvmName("create") operator fun invoke( diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/MultiToggleButton.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/MultiToggleButton.kt index c05a4444..8e112d6c 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/MultiToggleButton.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/MultiToggleButton.kt @@ -27,9 +27,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.Divider -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,7 +47,7 @@ internal fun ChatTheme.MultiToggleButton( modifier: Modifier = Modifier, onToggleChange: (String) -> Unit ) { - val selectedTint = colors.primary + val selectedTint = colorScheme.primary val unselectedTint = Color.Unspecified Surface( @@ -65,7 +65,7 @@ internal fun ChatTheme.MultiToggleButton( val textColor = if (isSelected) Color.White else Color.Unspecified if (index != 0) { - Divider( + HorizontalDivider( color = Color.LightGray, modifier = Modifier .fillMaxHeight() diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Scaffold.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Scaffold.kt index 75be1b65..c0a33f75 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Scaffold.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Scaffold.kt @@ -27,22 +27,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material.AppBarDefaults -import androidx.compose.material.BottomAppBar -import androidx.compose.material.FabPosition -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.FloatingActionButtonDefaults -import androidx.compose.material.FloatingActionButtonElevation -import androidx.compose.material.Icon -import androidx.compose.material.ScaffoldState -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.Icon +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -60,45 +59,42 @@ import androidx.compose.ui.unit.Dp import coil.ImageLoader import coil.compose.AsyncImage import kotlinx.coroutines.Dispatchers +import androidx.compose.material3.Scaffold as M3Scaffold @Composable internal fun ChatTheme.Scaffold( modifier: Modifier = Modifier, - scaffoldState: ScaffoldState = rememberScaffoldState(), topBar: @Composable () -> Unit = {}, bottomBar: @Composable () -> Unit = {}, - snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, - isFloatingActionButtonDocked: Boolean = false, - backgroundColor: Color = colors.background, - contentColor: Color = colors.onBackground, + backgroundColor: Color = colorScheme.background, + contentColor: Color = colorScheme.onBackground, content: @Composable (PaddingValues) -> Unit, ) { - androidx.compose.material.Scaffold( + M3Scaffold( modifier = modifier, - scaffoldState = scaffoldState, topBar = topBar, bottomBar = bottomBar, - snackbarHost = snackbarHost, + snackbarHost = { SnackbarHost(snackbarHostState) }, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, - isFloatingActionButtonDocked = isFloatingActionButtonDocked, - backgroundColor = backgroundColor, + containerColor = backgroundColor, contentColor = contentColor, content = content, ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ChatTheme.TopBar( title: String, modifier: Modifier = Modifier, logo: Any? = images.logo, - navigationIcon: @Composable (() -> Unit)? = null, - backgroundColor: Color = colors.primary, - contentColor: Color = colors.onPrimary, - elevation: Dp = AppBarDefaults.TopAppBarElevation, + navigationIcon: @Composable () -> Unit = { }, + containerColor: Color = colorScheme.primary, + contentColor: Color = colorScheme.onPrimary, actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( @@ -108,25 +104,26 @@ internal fun ChatTheme.TopBar( modifier = modifier, navigationIcon = navigationIcon, actions = actions, - backgroundColor = backgroundColor, - contentColor = contentColor, - elevation = elevation, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + navigationIconContentColor = contentColor, + titleContentColor = contentColor, + actionIconContentColor = contentColor, + ), ) } @Composable internal fun ChatTheme.BottomBar( modifier: Modifier = Modifier, - backgroundColor: Color = colors.primary, - contentColor: Color = colors.onPrimary, - elevation: Dp = AppBarDefaults.BottomAppBarElevation, + backgroundColor: Color = colorScheme.primary, + contentColor: Color = colorScheme.onPrimary, content: @Composable RowScope.() -> Unit ) { BottomAppBar( modifier = modifier, - backgroundColor = backgroundColor, + containerColor = backgroundColor, contentColor = contentColor, - elevation = elevation, content = content ) } @@ -167,8 +164,8 @@ internal fun ChatTheme.Fab( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = shapes.small.copy(CornerSize(percent = 50)), - backgroundColor: Color = colors.primary, - contentColor: Color = colors.onPrimary, + containerColor: Color = colorScheme.primary, + contentColor: Color = colorScheme.onPrimary, elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), ) { FloatingActionButton( @@ -176,19 +173,20 @@ internal fun ChatTheme.Fab( modifier = modifier, interactionSource = interactionSource, shape = shape, - backgroundColor = backgroundColor, + containerColor = containerColor, contentColor = contentColor, elevation = elevation, ) { Icon( painter = icon, contentDescription = contentDescription?.let { stringResource(id = it) }, - tint = colors.onPrimary + tint = colorScheme.onPrimary ) } } -@Preview(showBackground = true, showSystemUi = true) +@ExperimentalMaterial3Api +@Preview @Composable private fun ScaffoldPreview() { ChatTheme { @@ -207,8 +205,8 @@ private fun ScaffoldPreview() { .padding(it) .fillMaxWidth() .fillMaxHeight() - .background(ChatTheme.colors.background), - contentColor = ChatTheme.colors.onBackground + .background(ChatTheme.colorScheme.background), + contentColor = ChatTheme.colorScheme.onBackground ) { Text("Freddie") } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SelectionFrame.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SelectionFrame.kt index 3f32bd53..6c8ae1f4 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SelectionFrame.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SelectionFrame.kt @@ -16,28 +16,62 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.foundation.BorderStroke -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons.Outlined +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.tooling.preview.Preview +import com.nice.cxonechat.ui.composable.theme.ChatTheme.space @Composable internal fun ChatTheme.SelectionFrame( modifier: Modifier = Modifier, selected: Boolean = false, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { val strokeWidth = if (selected) space.selectedFrameWidth else space.unselectedFrameWidth Surface( modifier = modifier, shape = chatShapes.selectionFrame, - elevation = strokeWidth, + shadowElevation = strokeWidth, + tonalElevation = strokeWidth, content = content, border = BorderStroke( strokeWidth, - if (selected) MaterialTheme.colors.primary else LocalContentColor.current + if (selected) MaterialTheme.colorScheme.primary else LocalContentColor.current ) ) } + +@Composable +@Preview +private fun PreviewSelectionFrame() { + var selected by remember { mutableStateOf(false) } + ChatTheme { + Column( + modifier = Modifier.padding(space.small), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ChatTheme.SelectionFrame( + modifier = Modifier.padding(space.medium), + selected = selected + ) { + Icon(Outlined.Favorite, contentDescription = null, modifier = Modifier.padding(space.medium)) + } + Switch(selected, onCheckedChange = { selected = it }) + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Shape.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Shape.kt index 0c9c2075..9f663eee 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Shape.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Shape.kt @@ -16,7 +16,7 @@ package com.nice.cxonechat.ui.composable.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes +import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp internal val Shapes = Shapes( diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SwipeToDismiss.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SwipeToDismiss.kt index 4c92c1d9..fcda4fcb 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SwipeToDismiss.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SwipeToDismiss.kt @@ -20,23 +20,23 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material.Card -import androidx.compose.material.DismissDirection -import androidx.compose.material.DismissState -import androidx.compose.material.DismissValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FixedThreshold -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.ThresholdConfig import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.rememberDismissState +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxState +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -47,17 +47,14 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ChatTheme.SwipeToDismiss( - dismissState: DismissState, + dismissState: SwipeToDismissBoxState, icon: ImageVector, contentDescription: String, modifier: Modifier = Modifier, - directions: Set = setOf(DismissDirection.EndToStart), - dismissThresholds: (DismissDirection) -> ThresholdConfig = { - FixedThreshold(Space().dismissThreshold) - }, + directions: Set = setOf(SwipeToDismissBoxValue.EndToStart), background: @Composable RowScope.() -> Unit = { SwipeToDismissBackground( dismissState = dismissState, @@ -65,42 +62,45 @@ internal fun ChatTheme.SwipeToDismiss( contentDescription = contentDescription ) }, - content: @Composable RowScope.() -> Unit, + content: @Composable ColumnScope.() -> Unit, ) { - androidx.compose.material.SwipeToDismiss( + SwipeToDismissBox( state = dismissState, modifier = modifier, - directions = directions, - dismissThresholds = dismissThresholds, - background = background, - ) { - // Wrap the row content in a card or the dismiss background bleeds through - // the content when swiping. - Card(shape = shapes.medium.copy(CornerSize(0.dp))) { - content() + enableDismissFromStartToEnd = directions.contains(SwipeToDismissBoxValue.StartToEnd), + enableDismissFromEndToStart = directions.contains(SwipeToDismissBoxValue.EndToStart), + backgroundContent = background, + content = { + // Wrap the row content in a card or the dismiss background bleeds through + // the content when swiping. + Card(shape = shapes.medium.copy(CornerSize(0.dp))) { + content() + } } - } + ) } -@ExperimentalMaterialApi +@ExperimentalMaterial3Api @Composable internal fun ChatTheme.SwipeToDismissBackground( - dismissState: DismissState, + dismissState: SwipeToDismissBoxState, icon: ImageVector, contentDescription: String, ) { // Don't draw background if there's no swipe happening - dismissState.dismissDirection ?: return + dismissState.dismissDirection val color by animateColorAsState( when (dismissState.targetValue) { - DismissValue.Default -> colors.background + SwipeToDismissBoxValue.Settled -> colorScheme.background else -> Color.Red - } + }, + label = "color" ) val alignment = Alignment.CenterEnd val scale by animateFloatAsState( - if (dismissState.targetValue == DismissValue.Default) 0.75f else 1f + if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) 0.75f else 1f, + label = "scale" ) Box( @@ -118,14 +118,14 @@ internal fun ChatTheme.SwipeToDismissBackground( } } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Preview(showSystemUi = true, showBackground = true) @Composable private fun SwipeToDismissPreview() { - val dismissState = rememberDismissState() + val dismissState = rememberSwipeToDismissBoxState() ChatTheme { - Card(backgroundColor = Color.Yellow) { + Card(colors = CardDefaults.cardColors(containerColor = Color.Yellow)) { ChatTheme.SwipeToDismiss( dismissState = dismissState, icon = Icons.Default.Delete, @@ -133,7 +133,7 @@ private fun SwipeToDismissPreview() { modifier = Modifier.fillMaxWidth() ) { Card( - backgroundColor = ChatTheme.colors.background, + colors = CardDefaults.cardColors(containerColor = ChatTheme.colorScheme.background), modifier = Modifier.fillMaxWidth() ) { Column( diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/TextField.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/TextField.kt index 731c1492..ef143cba 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/TextField.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/TextField.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.OutlinedTextField +import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ThemeColors.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ThemeColors.kt index 4a991b91..7002bb68 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ThemeColors.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ThemeColors.kt @@ -24,20 +24,18 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.nice.cxonechat.Public /** A set of colors to be applied in either dark or light mode. */ @Suppress( "ComplexInterface" // Serves as definition of constants. ) -@Public interface ThemeColors { /** Android primary color. */ val primary: Color @@ -93,7 +91,6 @@ interface ThemeColors { * @param positionInQueueBackground color for position in queue panel. * @param positionInQueueForeground color for position in queue panel. */ - @Public @JvmStatic @JvmName("create") @Suppress("LongParameterList") @@ -159,12 +156,12 @@ private fun PreviewThemeColors() { private fun ThemeColorsList() { ChatTheme { val colors = listOf( - "primary" to ChatTheme.colors.primary, - "onPrimary" to ChatTheme.colors.onPrimary, - "background" to ChatTheme.colors.background, - "onBackground" to ChatTheme.colors.onBackground, - "secondary" to ChatTheme.colors.secondary, - "onSecondary" to ChatTheme.colors.onSecondary, + "primary" to ChatTheme.colorScheme.primary, + "onPrimary" to ChatTheme.colorScheme.onPrimary, + "background" to ChatTheme.colorScheme.background, + "onBackground" to ChatTheme.colorScheme.onBackground, + "secondary" to ChatTheme.colorScheme.secondary, + "onSecondary" to ChatTheme.colorScheme.onSecondary, ) Surface { Column( diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Type.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Type.kt index ddb04b72..53615765 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Type.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Type.kt @@ -15,7 +15,7 @@ package com.nice.cxonechat.ui.composable.theme -import androidx.compose.material.Typography +import androidx.compose.material3.Typography // Set of Material typography styles to start with internal val Typography = Typography() diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/customvalues/CVFieldList.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/customvalues/CVFieldList.kt index c5b031c5..c2882d9a 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/customvalues/CVFieldList.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/customvalues/CVFieldList.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Card +import androidx.compose.material3.Card import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadFragment.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadFragment.kt deleted file mode 100644 index 9984b011..00000000 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadFragment.kt +++ /dev/null @@ -1,650 +0,0 @@ -/* - * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. - * - * Licensed under the NICE License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE - * - * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON - * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS - * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. - */ - -package com.nice.cxonechat.ui.main - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.TextView -import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.ActivityResultRegistry -import androidx.activity.result.contract.ActivityResultContracts.GetContent -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions -import androidx.activity.result.contract.ActivityResultContracts.RequestPermission -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle.State -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.google.gson.Gson -import com.nice.cxonechat.message.Attachment -import com.nice.cxonechat.ui.EditCustomValuesDialog -import com.nice.cxonechat.ui.EditThreadNameDialog -import com.nice.cxonechat.ui.R.string -import com.nice.cxonechat.ui.composable.conversation.AudioPlayerDialog -import com.nice.cxonechat.ui.composable.conversation.AudioRecordingUiState -import com.nice.cxonechat.ui.composable.conversation.ChatConversation -import com.nice.cxonechat.ui.composable.conversation.EndConversationDialog -import com.nice.cxonechat.ui.composable.conversation.SelectAttachmentsDialog -import com.nice.cxonechat.ui.composable.conversation.model.ConversationUiState -import com.nice.cxonechat.ui.composable.generic.ImageViewerDialogCard -import com.nice.cxonechat.ui.composable.generic.VideoViewerDialogCard -import com.nice.cxonechat.ui.composable.theme.Alert -import com.nice.cxonechat.ui.composable.theme.BusySpinner -import com.nice.cxonechat.ui.composable.theme.ChatTheme -import com.nice.cxonechat.ui.customvalues.mergeWithCustomField -import com.nice.cxonechat.ui.databinding.CustomSnackBarBinding -import com.nice.cxonechat.ui.databinding.FragmentChatThreadBinding -import com.nice.cxonechat.ui.domain.AttachmentSharingRepository -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.AudioPlayer -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.CustomValues -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.EditThreadName -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.EndContact -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ErrorAttachmentNotSupported -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ErrorAttachmentTooLarge -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ErrorUnableToReadAttachment -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.ImageViewer -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.None -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.SelectAttachments -import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.VideoPlayer -import com.nice.cxonechat.ui.main.ChatThreadViewModel.OnPopupActionState.ReceivedOnPopupAction -import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.Failure -import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.Success -import com.nice.cxonechat.ui.model.EndConversationChoice.CLOSE_CHAT -import com.nice.cxonechat.ui.model.EndConversationChoice.NEW_CONVERSATION -import com.nice.cxonechat.ui.model.EndConversationChoice.SHOW_TRANSCRIPT -import com.nice.cxonechat.ui.storage.ValueStorage -import com.nice.cxonechat.ui.util.checkPermissions -import com.nice.cxonechat.ui.util.contentDescription -import com.nice.cxonechat.ui.util.openWithAndroid -import com.nice.cxonechat.ui.util.repeatOnViewOwnerLifecycle -import com.nice.cxonechat.ui.util.showRationale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.json.JSONObject -import org.json.JSONTokener -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.activityViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel -import java.util.UUID - -/** - * Fragment presenting UI of one concrete chat thread (conversation). - */ -@Suppress( - "TooManyFunctions", // Legacy for now - "LargeClass", -) -class ChatThreadFragment : Fragment() { - - private val chatViewModel: ChatThreadViewModel by viewModel() - private val chatModel: ChatViewModel by activityViewModel() - - private val audioViewModel: AudioRecordingViewModel by viewModel() - - private val activityLauncher by lazy { - ActivityLauncher(requireActivity().activityResultRegistry) - .also(lifecycle::addObserver) - } - - private val requestPermissionLauncher: ActivityResultLauncher = - registerForActivityResult(RequestPermission()) { isGranted -> - if (!isGranted) { - AlertDialog.Builder(requireContext()) - .setTitle(string.no_notifications_title) - .setMessage(string.no_notifications_message) - .setNeutralButton(string.ok, null) - .show() - } - } - - private val audioRequestPermissionLauncher = registerForActivityResult( - RequestMultiplePermissions() - ) { requestResults: Map? -> - if (requestResults.orEmpty().any { !it.value }) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(string.recording_audio_permission_denied_title) - .setMessage(string.recording_audio_permission_denied_body) - .setNeutralButton(string.ok) { dialog, _ -> - dialog.dismiss() - } - } - } - - private val valueStorage: ValueStorage by inject() - - private val attachmentSharingRepository: AttachmentSharingRepository by inject() - - private var fragmentBinding: FragmentChatThreadBinding? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = FragmentChatThreadBinding.inflate(layoutInflater, container, false) - fragmentBinding = binding - registerOnPopupActionListener() - registerChatMetadataListener() - registerMessageListener() - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - checkNotificationPermissions( - Manifest.permission.POST_NOTIFICATIONS, - string.notifications_rationale - ) - } - activityLauncher // activity launcher has to self-register before onStart - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // Workaround for issue that the optionsMenu is not updated, - // until activity is resumed or user navigates elsewhere. - activity?.invalidateOptionsMenu() - } - - private fun checkNotificationPermissions(permission: String, @StringRes rationale: Int) { - when { - ContextCompat.checkSelfPermission( - requireContext(), - permission - ) == PackageManager.PERMISSION_GRANTED -> Unit - - shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> - showRationale(rationale) { - requestPermissionLauncher.launch(permission) - } - - else -> - requestPermissionLauncher.launch(permission) - } - } - - private fun registerOnPopupActionListener() { - repeatOnViewOwnerLifecycle { - chatViewModel.actionState.filterIsInstance().collect { - val rawVariables = it.variables - try { - val variables = Gson().toJson(rawVariables) - val jsonObject = JSONTokener(variables).nextValue() as JSONObject - val headingText = jsonObject.getString("headingText") - val bodyText = jsonObject.getString("bodyText") - val action = jsonObject.getJSONObject("action") - val actionText = action.getString("text") - val actionUrl = action.getString("url") - val data = SnackbarSetupData(headingText, bodyText, actionText, actionUrl, it) - showSnackBar(data) - } catch (expected: Exception) { - Toast.makeText( - requireContext(), - "Unable to decode ReceivedOnPopupAction", - Toast.LENGTH_SHORT - ).show() - } - } - } - } - - private fun registerChatMetadataListener() { - repeatOnViewOwnerLifecycle { - chatViewModel.chatMetadata.collect { chatData -> - activity?.title = chatData.threadName - } - } - } - - private fun registerMessageListener() { - repeatOnViewOwnerLifecycle { - val threadNameFlow = chatViewModel.chatMetadata.map { it.threadName } - - fragmentBinding?.composeThreadView!!.setContent { - ContentView(threadNameFlow) - DialogView() - } - } - } - - @Composable - private fun DialogView() { - when (val dialog = chatViewModel.dialogShown.collectAsState(None).value) { - None -> Unit - CustomValues -> CustomValuesDialog() - EditThreadName -> EditThreadNameDialog( - threadName = chatViewModel.selectedThreadName.orEmpty(), - onCancel = chatViewModel::dismissDialog, - onAccept = chatViewModel::confirmEditThreadName - ) - - is AudioPlayer -> AudioPlayerDialog( - url = dialog.url, - title = dialog.title, - onCancel = chatViewModel::dismissDialog, - ) - - is SelectAttachments -> SelectAttachmentsDialog( - attachments = dialog.attachments, - title = dialog.title.orEmpty(), - onAttachmentTapped = ::onAttachmentClicked, - onCancel = chatViewModel::dismissDialog, - onShare = ::onShare, - ) - - is ImageViewer -> ImageViewerDialogCard( - image = dialog.image, - title = dialog.title, - onDismiss = chatViewModel::dismissDialog, - ) - - is VideoPlayer -> VideoViewerDialogCard( - uri = dialog.uri, - title = dialog.title, - onDismiss = chatViewModel::dismissDialog - ) - - ErrorAttachmentNotSupported -> ErrorDialog( - title = stringResource(id = string.attachment_upload_failure), - message = stringResource(id = string.attachment_not_supported), - ) - - ErrorAttachmentTooLarge -> ErrorDialog( - title = stringResource(id = string.attachment_upload_failure), - message = stringResource(id = string.attachment_too_large, chatViewModel.maxAttachmentSize), - ) - - ErrorUnableToReadAttachment -> ErrorDialog( - title = stringResource(id = string.attachment_upload_failure), - message = stringResource(id = string.attachment_read_error) - ) - - EndContact -> EndContactDialog() - } - - if (chatViewModel.preparingToShare.collectAsState().value) { - BusySpinner(message = stringResource(string.preparing)) - } - } - - @Composable - private fun EndContactDialog() { - ChatTheme { - ChatTheme.EndConversationDialog( - assignedAgent = chatViewModel.chatMetadata.collectAsState(initial = null).value?.agent, - onDismiss = chatViewModel::dismissDialog, - onUserSelection = { - when (it) { - SHOW_TRANSCRIPT -> { - // no-op required - } - - NEW_CONVERSATION -> chatModel.refreshThreadState(true) - CLOSE_CHAT -> requireActivity().finish() - } - } - ) - } - } - - @Composable - private fun ErrorDialog( - title: String, - message: String, - ) { - ChatTheme.Alert( - title = title, - message = message, - dismissLabel = stringResource(id = string.ok), - onDismiss = chatViewModel::dismissDialog - ) - } - - @Composable - private fun CustomValuesDialog() { - EditCustomValuesDialog( - title = stringResource(string.edit_custom_field_title), - fields = chatViewModel - .preChatSurvey - ?.fields - .orEmpty() - .mergeWithCustomField( - chatViewModel.customValues - ), - onCancel = chatViewModel::cancelEditingCustomValues, - onConfirm = chatViewModel::confirmEditingCustomValues - ) - } - - @OptIn(ExperimentalComposeUiApi::class) - @Composable - private fun ContentView(threadNameFlow: Flow) { - val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() - - /** Key to force refresh if the selected thread has changed. */ - val threadId by remember { - chatViewModel.chatThreadHandler.map { it.get().id } - }.collectAsState(NIL_UUID) - if (lifecycleState === State.RESUMED) { - LaunchedEffect(lifecycleState, threadId) { - if (threadId !== NIL_UUID) { - chatViewModel.refresh() - } - } - } - - ChatTheme { - ChatConversation( - conversationState = ConversationUiState( - threadName = threadNameFlow, - sdkMessages = chatViewModel.messages, - typingIndicator = chatViewModel.agentState, - positionInQueue = chatViewModel.positionInQueue, - sendMessage = chatViewModel::sendMessage, - loadMore = chatViewModel::loadMore, - canLoadMore = chatViewModel.canLoadMore, - onStartTyping = ::onStartTyping, - onStopTyping = ::onStopTyping, - onAttachmentClicked = ::onAttachmentClicked, - onMoreClicked = ::onMoreClicked, - onShare = ::onShare, - isMultiThreaded = chatViewModel.isMultiThreadEnabled, - isLiveChat = chatViewModel.isLiveChat, - hasQuestions = chatViewModel.hasQuestions, - isArchived = chatViewModel.isArchived, - ), - audioRecordingState = AudioRecordingUiState( - uriFlow = audioViewModel.recordedUriFlow, - isRecordingFlow = audioViewModel.recordingFlow, - onDismiss = ::onDismissRecording, - onApprove = chatViewModel::sendAttachment, - onAudioRecordToggle = ::onTriggerRecording - ), - onAttachmentTypeSelection = { - activityLauncher.getDocument(it.toTypedArray()) - }, - onEditThreadName = ::showEditThreadName, - onEditThreadValues = ::showEditCustomValues, - onEndContact = ::endContact, - displayEndConversation = chatViewModel::showEndContactDialog, - modifier = Modifier.semantics { - testTagsAsResourceId = true // Enabled for UI test automation - } - ) - } - } - - private fun showEditThreadName() { - chatViewModel.editThreadName() - } - - private fun showEditCustomValues() { - chatViewModel.startEditingCustomValues() - } - - private fun endContact() { - chatViewModel.endContact() - } - - private fun onMoreClicked(attachments: List, title: String) { - chatViewModel.selectAttachments(attachments, title) - } - - private fun onShare(attachments: Collection) { - chatViewModel.beginPrepareAttachments() - - val context = context ?: return - lifecycleScope.launch(Dispatchers.IO) { - val intent = attachmentSharingRepository.createSharingIntent(attachments, context) - chatViewModel.finishPrepareAttachments() - lifecycleScope.launch(Dispatchers.Main) { - if (intent == null) { - Toast.makeText( - requireContext(), - getString(string.prepare_attachments_failure), - Toast.LENGTH_SHORT - ).show() - } else { - startActivity(Intent.createChooser(intent, null)) - } - } - } - } - - private fun onAttachmentClicked(attachment: Attachment) { - val url = attachment.url - val mimeType = attachment.mimeType.orEmpty() - val title by lazy { attachment.contentDescription } - when { - mimeType.startsWith("image/") -> - chatViewModel.showImage(url, title ?: getString(string.image_preview_title)) - - mimeType.startsWith("video/") -> - chatViewModel.showVideo(url, title ?: getString(string.video_preview_title)) - - mimeType.startsWith("audio/") -> chatViewModel.playAudio(url, title) - else -> openWithAndroid(attachment) - } - } - - private fun openWithAndroid(attachment: Attachment) { - val context = context ?: return - - if (!context.openWithAndroid(attachment.url, attachment.mimeType)) { - AlertDialog.Builder(context) - .setTitle(string.unsupported_type_title) - .setMessage(getString(string.unsupported_type_message, attachment.mimeType)) - .setNegativeButton(string.cancel, null) - .show() - } - } - - override fun onDestroyView() { - super.onDestroyView() - fragmentBinding = null - } - - // TODO implement menu handling - - private fun onStartTyping() { - chatViewModel.reportThreadRead() - chatViewModel.reportTypingStarted() - } - - private fun onStopTyping() { - chatViewModel.reportTypingEnd() - } - - private fun showSnackBar(data: SnackbarSetupData) { - val binding = fragmentBinding ?: return - val parentLayout = binding.root - val snackbar = Snackbar.make(parentLayout, "", Snackbar.LENGTH_INDEFINITE) - val snackBinding = CustomSnackBarBinding.inflate(layoutInflater, null, false) - snackbar.view.setBackgroundColor(Color.TRANSPARENT) - - val snackbarLayout = snackbar.view as ViewGroup - snackbarLayout.setPadding(0, 0, 0, 0) - - val headingTextView: TextView = snackBinding.headingTextView - val bodyTextView: TextView = snackBinding.bodyTextView - val actionTextView: TextView = snackBinding.actionTextView - val closeButton: ImageButton = snackBinding.closeButton - - headingTextView.text = data.headingText - bodyTextView.text = data.bodyText - actionTextView.text = data.actionText - - val action = data.action - - actionTextView.setOnClickListener { - chatViewModel.reportOnPopupActionClicked(action) - // TODO build intent for the actionUrl - chatViewModel.reportOnPopupAction(Success, action) - snackbar.dismiss() - } - - closeButton.setOnClickListener { - chatViewModel.reportOnPopupAction(Failure, action) - snackbar.dismiss() - } - - snackbarLayout.addView(snackBinding.root, 0) - snackbar.show() - - chatViewModel.reportOnPopupActionDisplayed(action) - } - - @SuppressLint( - "MissingPermission" // permission state is checked by `checkPermissions()` method - ) - private suspend fun onTriggerRecording(): Boolean { - if (!checkPermissions( - valueStorage = valueStorage, - permissions = requiredRecordAudioPermissions, - rationale = string.recording_audio_permission_rationale, - onAcceptPermissionRequest = audioRequestPermissionLauncher::launch - ) - ) { - return false - // Permissions will need to be sorted out first, user will have to click the button again after that - } - val context = requireContext() - return if (audioViewModel.recordingFlow.value) { - audioViewModel.stopRecording(context) - } else { - audioViewModel.startRecording(context).isSuccess - } - } - - @SuppressLint( - "MissingPermission" // permission state is checked by `checkPermissions()` method - ) - private fun onDismissRecording() { - lifecycleScope.launch { - if (!checkPermissions( - valueStorage = valueStorage, - permissions = requiredRecordAudioPermissions, - rationale = string.recording_audio_permission_rationale, - onAcceptPermissionRequest = audioRequestPermissionLauncher::launch - ) - ) { - return@launch - } - audioViewModel.deleteLastRecording(requireContext()) { - Toast.makeText(requireContext(), string.record_audio_failed_cleanup, Toast.LENGTH_LONG).show() - } - } - } - - companion object { - internal const val SENDER_ID = "1" - - private val NIL_UUID = UUID(0, 0) - - internal val requiredRecordAudioPermissions = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { - setOf(Manifest.permission.RECORD_AUDIO) - } else { - setOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - - private data class SnackbarSetupData( - val headingText: String, - val bodyText: String, - val actionText: String, - val actionUrl: String, - val action: ReceivedOnPopupAction, - ) - } - - /** - * [LifecycleObserver][androidx.lifecycle.LifecycleObserver] intended to interface between the [ChatThreadFragment] - * and document picker activities to pick attachments. This is now the recommended method for calling the document - * picker to fetch an image, video, or other document. - * - * At some point this could be expanded to support - * [TakePicture][androidx.activity.result.contract.ActivityResultContracts.TakePicture] and friends. - */ - inner class ActivityLauncher( - private val registry: ActivityResultRegistry - ) : DefaultLifecycleObserver { - private var getContent: ActivityResultLauncher? = null - private var getDocument: ActivityResultLauncher>? = null - - override fun onCreate(owner: LifecycleOwner) { - getContent = registry.register("com.nice.cxonechat.ui.content", owner, GetContent()) { uri -> - val safeUri = uri ?: return@register - - chatViewModel.sendAttachment(safeUri) - } - getDocument = registry.register("com.nice.cxonechat.ui.document", owner, OpenDocument()) { uri -> - val safeUri = uri ?: return@register - - chatViewModel.sendAttachment(safeUri) - } - } - - /** - * start a foreign activity to find an attachment with the indicated mime types - * - * [mimeTypes] is one of the strings supplied by the chat instance. - * - * Note that this will work for finding existing resources, but not for opening - * the camera for photos or videos. - * - * @param mimeTypes attachment types to find. - * - */ - fun getDocument(mimeTypes: Array) { - if (mimeTypes.size == 1) { - getContent?.launch(mimeTypes[0]) - } else { - getDocument?.launch(mimeTypes) - } - } - } -} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadViewModel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadViewModel.kt index d799fbc7..77987936 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadViewModel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadViewModel.kt @@ -57,8 +57,8 @@ import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.CustomValues import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.EditThreadName import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.None import com.nice.cxonechat.ui.main.ChatThreadViewModel.Dialogs.SelectAttachments -import com.nice.cxonechat.ui.main.ChatThreadViewModel.OnPopupActionState.Empty -import com.nice.cxonechat.ui.main.ChatThreadViewModel.OnPopupActionState.ReceivedOnPopupAction +import com.nice.cxonechat.ui.main.ChatThreadViewModel.PopupActionState.Empty +import com.nice.cxonechat.ui.main.ChatThreadViewModel.PopupActionState.ReceivedPopupAction import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.Clicked import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.Displayed import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.Failure @@ -76,11 +76,13 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel @@ -106,8 +108,19 @@ internal class ChatThreadViewModel( get() = threads.preChatSurvey val hasQuestions: Boolean get() = preChatSurvey?.fields?.isEmpty() == false - val chatThreadHandler = selectedThreadRepository.chatThreadHandlerFlow - private val chatThreadFlow: Flow = chatThreadHandler.flatMapLatest { it.flow } + val chatThreadHandler = selectedThreadRepository + .chatThreadHandlerFlow + .shareIn(viewModelScope, SharingStarted.Eagerly, 1) + + private val chatThreadFlow = chatThreadHandler + .flatMapLatest { it.flow } + .onEach { newThread -> + val sentMessageThreadId = sentMessagesFlow.firstOrNull()?.asIterable()?.firstOrNull()?.value?.threadId + if (newThread.id != sentMessageThreadId) { + sentMessagesFlow.value = emptyMap() + } + } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) /** Tracks messages before they are confirmed as received by backend. */ private val sentMessagesFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) @@ -148,12 +161,12 @@ internal class ChatThreadViewModel( .map { it.hasMoreMessagesToLoad } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - private val mutableActionState: MutableStateFlow = MutableStateFlow(Empty) + private val mutableActionState: MutableStateFlow = MutableStateFlow(Empty) private val actionHandler = chat.actions() private val messageHandler = chatThreadHandler.mapLatest(ChatThreadHandler::messages) private val eventHandler = chatThreadHandler.mapLatest(ChatThreadHandler::events) - val actionState: StateFlow = mutableActionState.asStateFlow() + val actionState: StateFlow = mutableActionState.asStateFlow() val customValues: List get() = selectedThreadRepository.chatThreadHandler?.get()?.fields ?: listOf() @@ -214,7 +227,7 @@ internal class ChatThreadViewModel( init { val listener = OnPopupActionListener { variables, metadata -> - mutableActionState.value = ReceivedOnPopupAction(variables, metadata) + mutableActionState.value = ReceivedPopupAction(variables, metadata) } actionHandler.onPopup(listener) } @@ -315,17 +328,17 @@ internal class ChatThreadViewModel( } } - fun reportOnPopupActionDisplayed(action: ReceivedOnPopupAction) { + fun reportOnPopupActionDisplayed(action: ReceivedPopupAction) { chat.events().proactiveActionDisplay(action.metadata) } - fun reportOnPopupActionClicked(action: ReceivedOnPopupAction) { + fun reportOnPopupActionClicked(action: ReceivedPopupAction) { chat.events().proactiveActionClick(action.metadata) } fun reportOnPopupAction( reportType: ReportOnPopupAction, - action: ReceivedOnPopupAction, + action: ReceivedPopupAction, ) { val events = chat.events() when (reportType) { @@ -343,7 +356,7 @@ internal class ChatThreadViewModel( } } - private fun clearOnPopupAction(action: ReceivedOnPopupAction) { + private fun clearOnPopupAction(action: ReceivedPopupAction) { if (mutableActionState.value == action) { mutableActionState.value = Empty } @@ -425,9 +438,20 @@ internal class ChatThreadViewModel( showDialog(Dialogs.EndContact) } - sealed interface OnPopupActionState { - object Empty : OnPopupActionState - data class ReceivedOnPopupAction(val variables: Any, val metadata: ActionMetadata) : OnPopupActionState + internal sealed interface PopupActionState { + data object Empty : PopupActionState + data class ReceivedPopupAction( + override val variables: Map, + val metadata: ActionMetadata, + ) : PopupActionState, PopupActionData + data class PreviewPopupAction( + override val variables: Map, + val metadata: Any, + ) : PopupActionState, PopupActionData + + sealed interface PopupActionData { + val variables: Map + } } enum class ReportOnPopupAction { @@ -472,7 +496,7 @@ private data class TemporarySentMessage( override val status: MessageStatus = Sending }, author = object : MessageAuthor() { - override val id: String = ChatThreadFragment.SENDER_ID + override val id: String = SENDER_ID override val firstName: String = "" override val lastName: String = "" override val imageUrl: String? = null @@ -493,7 +517,7 @@ private data class TemporarySentMessage( override val status: MessageStatus = Sending }, author = object : MessageAuthor() { - override val id: String = ChatThreadFragment.SENDER_ID + override val id: String = SENDER_ID override val firstName: String = "" override val lastName: String = "" override val imageUrl: String? = null @@ -502,4 +526,8 @@ private data class TemporarySentMessage( fallbackText = null, text = text ) + + private companion object { + private const val SENDER_ID = "com.cxone.chat.message.sender.1" + } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadsViewModel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadsViewModel.kt index 75fc7711..694c85a0 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadsViewModel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatThreadsViewModel.kt @@ -52,6 +52,7 @@ import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -74,7 +75,7 @@ internal class ChatThreadsViewModel( private val internalState: MutableStateFlow = MutableStateFlow(Initial) val createThreadFailure = MutableStateFlow(null as Failure?) - private val threadFlow = threadsHandler.flow + private val threadFlow = threadsHandler.flow.shareIn(viewModelScope, SharingStarted.Lazily, 1) private val threadList: StateFlow> = threadFlow .conflate() @@ -181,16 +182,20 @@ internal class ChatThreadsViewModel( internalState.value = ThreadSelected } - internal suspend fun selectThreadById(threadId: UUID) = logger.timedScope("selectThreadById($threadId)") { - if (threadId == selectedThreadRepository.chatThreadHandler?.get()?.id) { - return@timedScope + internal fun selectThreadById(threadId: UUID) { + viewModelScope.launch(Dispatchers.Default) { + logger.timedScope("selectThreadById($threadId)") { + if (threadId == selectedThreadRepository.chatThreadHandler?.get()?.id) { + return@timedScope + } + val flow = threadFlow + refreshThreads() + val threadList = flow.first() + require(threadList.isNotEmpty()) + selectedThreadRepository.chatThreadHandler = threadsHandler.thread(threadList.first { it.id == threadId }) + internalState.value = ThreadSelected + } } - val flow = threadFlow - refreshThreads() - val threadList = flow.first() - require(threadList.isNotEmpty()) - selectedThreadRepository.chatThreadHandler = threadsHandler.thread(threadList.first { it.id == threadId }) - internalState.value = ThreadSelected } private suspend fun createThreadWorker(response: Sequence) = diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatViewModel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatViewModel.kt index cd3781ab..dfcff97e 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatViewModel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatViewModel.kt @@ -23,37 +23,29 @@ import com.nice.cxonechat.ChatEventHandlerActions.chatWindowOpen import com.nice.cxonechat.ChatInstanceProvider import com.nice.cxonechat.ChatMode import com.nice.cxonechat.ChatMode.LiveChat -import com.nice.cxonechat.ChatMode.MultiThread import com.nice.cxonechat.ChatMode.SingleThread -import com.nice.cxonechat.ChatState.Offline +import com.nice.cxonechat.ChatState import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.error +import com.nice.cxonechat.log.warning import com.nice.cxonechat.prechat.PreChatSurvey import com.nice.cxonechat.ui.data.flow import com.nice.cxonechat.ui.domain.SelectedThreadRepository import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.None import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.Survey -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.MultiThreadEnabled -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.NavigationFinished -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.SingleThreadCreated -import com.nice.cxonechat.ui.main.ChatViewModel.State.CreateSingleThread -import com.nice.cxonechat.ui.main.ChatViewModel.State.Initial -import com.nice.cxonechat.ui.main.ChatViewModel.State.SingleThreadCreationFailed -import com.nice.cxonechat.ui.main.ChatViewModel.State.SingleThreadPreChatSurveyRequired import com.nice.cxonechat.ui.model.CreateThreadResult.Failure import com.nice.cxonechat.ui.model.CreateThreadResult.Success import com.nice.cxonechat.ui.model.foldToCreateThreadResult import com.nice.cxonechat.ui.model.prechat.PreChatResponse import com.nice.cxonechat.ui.storage.ValueStorage import com.nice.cxonechat.ui.storage.getCustomerCustomValues +import com.nice.cxonechat.ui.util.Ignored import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel -import com.nice.cxonechat.ui.main.ChatViewModel.NavigationState.Offline as NavigationOffline @Suppress("TooManyFunctions") @KoinViewModel @@ -70,11 +62,10 @@ internal class ChatViewModel( private val events by lazy { chat.events() } - private val internalState: MutableStateFlow = MutableStateFlow(Initial) - sealed interface Dialogs { data object None : Dialogs - class Survey(val survey: PreChatSurvey) : Dialogs + data class Survey(val survey: PreChatSurvey) : Dialogs + data class ThreadCreationFailed(val failure: Failure) : Dialogs } private val showDialog = MutableStateFlow(None) @@ -83,27 +74,16 @@ internal class ChatViewModel( val chatMode: ChatMode get() = chat.chatMode - val state - get() = internalState - .asStateFlow() - .onSubscription { - internalState.value = resolveCurrentState() - } - val preChatSurvey get() = threads.preChatSurvey - internal fun setNavigationFinishedState() { - internalState.value = NavigationFinished - } - - internal fun refreshThreadState(reset: Boolean = false) { + internal fun refreshThreadState() { viewModelScope.launch { - internalState.value = resolveCurrentState(reset) + resolveCurrentState() } } - internal fun createThread() { + private fun createThread() { createThread(emptySequence()) } @@ -120,44 +100,24 @@ internal class ChatViewModel( }.foldToCreateThreadResult() when (result) { - is Failure -> internalState.value = SingleThreadCreationFailed(result) - Success -> { - internalState.value = SingleThreadCreated - dismissDialog() - } + is Failure -> showDialog(Dialogs.ThreadCreationFailed(result)) + Success -> dismissDialog() } } } - private suspend fun resolveCurrentState(reset: Boolean = false): State { - if (internalState.value === NavigationFinished && !reset) return NavigationFinished - - return when { - chatProvider.chatState === Offline -> NavigationOffline - chat.chatMode === MultiThread -> MultiThreadEnabled - listOf(SingleThread, LiveChat).contains(chat.chatMode) -> singleThreadChatState() - else -> { - error("Invalid chatMode/chatState combination: ${chat.chatMode}/${chatProvider.chatState}") - NavigationOffline + private suspend fun resolveCurrentState() { + if (listOf(SingleThread, LiveChat).contains(chat.chatMode)) { + when { + selectFirstThread() -> Ignored + else -> when (val chatSurvey = preChatSurvey) { + null -> createThread() + else -> showDialog(Survey(chatSurvey)) + } } } } - /** - * Determine the correct state for a single thread chat. - * - * when: - * - * - there's already a thread, use [SingleThreadCreated] - * - there's a survey, use [SingleThreadPreChatSurveyRequired] - * - otherwise use [CreateSingleThread] - */ - private suspend fun singleThreadChatState() = if (selectFirstThread()) { - SingleThreadCreated - } else { - preChatSurvey?.let(::SingleThreadPreChatSurveyRequired) ?: CreateSingleThread - } - /** * We're in single thread mode. Select the first thread if it exists. * @@ -193,73 +153,25 @@ internal class ChatViewModel( showDialog.value = None } - internal fun showPreChatSurvey(survey: PreChatSurvey) { - showDialog(Survey(survey)) - } - internal fun prepare(context: Context) { - chatProvider.prepare(context) + val chatState = chatProvider.chatState + if (chatState === ChatState.Initial) { + chatProvider.prepare(context) + } else { + warning("Chat already prepared, currentState: $chatState") + } } internal fun connect() { + val chatState = chatProvider.chatState + if (chatState !== ChatState.Prepared && chatState !== ChatState.ConnectionLost && chatState !== ChatState.Offline) { + error("Unable to connect chat in state: $chatState") + return + } chatProvider.connect() } internal fun close() { chatProvider.close() } - - /** - * Definition of navigation states for the view model. - */ - sealed interface NavigationState : State { - /** - * Navigtation should display an offline indication. - */ - data object Offline : NavigationState - - /** - * Navigation should be directed to multi-thread flow. - */ - data object MultiThreadEnabled : NavigationState - - /** - * Navigation should be directed to single thread chat. - */ - data object SingleThreadCreated : NavigationState - - /** - * Final state of navigation, either the activity has successfully navigated to [MultiThreadEnabled] - * state or [SingleThreadCreated] state. - */ - data object NavigationFinished : NavigationState - } - - /** - * Definition of states for the view model. - */ - sealed interface State { - /** - * Default state. - */ - data object Initial : State - - /** - * Single thread is ready to be created. - */ - data object CreateSingleThread : State - - /** - * Single thread creation requires prechat survey to be finished first, before thread can be created. - * @property survey Survey which should be presented to the user. - */ - data class SingleThreadPreChatSurveyRequired(val survey: PreChatSurvey) : State - - /** - * Single thread creation has resulted in an error. - * - * @property failure What type of failure has happened. - */ - data class SingleThreadCreationFailed(val failure: Failure) : State - } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorageExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorageExt.kt index 8bcd408f..0899cd61 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorageExt.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorageExt.kt @@ -15,22 +15,15 @@ package com.nice.cxonechat.ui.storage -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.nice.cxonechat.ui.storage.ValueStorage.StringKey.CustomerCustomValuesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.serialization.json.Json internal suspend fun ValueStorage.getCustomerCustomValues(): Map { - @Suppress("UNCHECKED_CAST") - val parameterized = TypeToken.getParameterized( - Map::class.java, - String::class.java, - String::class.java - ) as? TypeToken>? val json = getString(CustomerCustomValuesKey).firstNotBlankOrNull() return if (json != null) { - Gson().fromJson(json, parameterized) ?: emptyMap() + Json.Default.decodeFromString>(json) } else { emptyMap() } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/FragmentExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ActivityExt.kt similarity index 64% rename from chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/FragmentExt.kt rename to chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ActivityExt.kt index d6bbfdc5..ef1066ad 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/FragmentExt.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ActivityExt.kt @@ -15,15 +15,23 @@ package com.nice.cxonechat.ui.util +import android.app.Activity +import android.app.Activity.OVERRIDE_TRANSITION_CLOSE +import android.app.Activity.OVERRIDE_TRANSITION_OPEN +import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES import android.provider.Settings +import android.view.WindowManager.LayoutParams +import androidx.annotation.AnimRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -34,21 +42,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch -/** - * Launch [repeatOnLifecycle] with supplied parameters using [Fragment.getViewLifecycleOwner]'s [lifecycleScope]. - * - * @param state State on which the supplied [block] should be repeated, default is [State.RESUMED]. - * @param block Suspend function which should be launched in [androidx.lifecycle.LifecycleOwner.repeatOnLifecycle]. - */ -internal fun Fragment.repeatOnViewOwnerLifecycle( - state: State = State.RESUMED, - block: suspend CoroutineScope.() -> Unit, -) { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(state, block) - } -} - /** * Display dialog informing user that permission is required in order to provide functionality with supplied * rationale about details. @@ -57,8 +50,8 @@ internal fun Fragment.repeatOnViewOwnerLifecycle( * @param rationale String resource with the rationale. * @param onAcceptListener Action which is called when user clicks the positive button. */ -internal fun Fragment.showRationale(@StringRes rationale: Int, onAcceptListener: () -> Unit) { - MaterialAlertDialogBuilder(requireContext()) +internal fun Context.showRationale(@StringRes rationale: Int, onAcceptListener: () -> Unit) { + MaterialAlertDialogBuilder(this) .setTitle(getString(R.string.permission_requested)) .setMessage(rationale) .setNegativeButton(R.string.cancel, null) @@ -81,14 +74,14 @@ internal fun Fragment.showRationale(@StringRes rationale: Int, onAcceptListener: * * @return `true` if all permissions were already granted, otherwise `false`. */ -internal suspend fun Fragment.checkPermissions( +internal suspend fun Activity.checkPermissions( valueStorage: ValueStorage, permissions: Iterable, @StringRes rationale: Int, onAcceptPermissionRequest: (Array) -> Unit, ): Boolean { val missingPermissionsSet = permissions.filterNot { permission -> - ContextCompat.checkSelfPermission(requireContext(), permission) == PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED }.toSet() val missingPermissions = missingPermissionsSet.toTypedArray() val result = missingPermissions.isEmpty() @@ -117,7 +110,7 @@ internal suspend fun Fragment.checkPermissions( // Since the permissions can't be requested directly again, redirect user to the app settings. showRationale(rationale) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.fromParts("package", requireContext().packageName, null) + intent.data = Uri.fromParts("package", packageName, null) startActivity(intent) } } @@ -125,3 +118,69 @@ internal suspend fun Fragment.checkPermissions( } return result } + +/** + * This is workaround for issue when keyboard is shown window content pans under the toolbar and keyboard overlaps + * window contents. + * There should be a better solution. + */ +@Suppress("DEPRECATION") +internal fun Activity.applyFixesForKeyboardInput() { + if (VERSION.SDK_INT >= VERSION_CODES.R) window.setDecorFitsSystemWindows(true) + window.setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE) +} + +internal fun Activity.overrideOpenAnimation( + @AnimRes enterAnim: Int, + @AnimRes exitAnim: Int, +) { + if (VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { + @Suppress("DEPRECATION") + overridePendingTransition(enterAnim, exitAnim) + } else { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, enterAnim, exitAnim) + } +} + +/* + * This could be defined as a normal method on ChatActivity, but this seems to keep it paired with + * overrideCloseAnimation better. + */ +internal fun Activity.overrideCloseAnimation( + @AnimRes enterAnim: Int, + @AnimRes exitAnim: Int, +) { + if (VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { + @Suppress("DEPRECATION") + overridePendingTransition(enterAnim, exitAnim) + } else { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, enterAnim, exitAnim) + } +} + +internal fun Activity.checkNotificationPermissions(permission: String, @StringRes rationale: Int, requestPermission: (String) -> Unit) { + val isPermissionGranted = ContextCompat.checkSelfPermission( + applicationContext, + permission + ) == PackageManager.PERMISSION_GRANTED + when { + isPermissionGranted -> Ignored + shouldShowRequestPermissionRationale(permission) -> showRationale(rationale) { requestPermission(permission) } + else -> requestPermission(permission) + } +} + +/** + * Launch [repeatOnLifecycle] with supplied parameters using [LifecycleOwner]'s [lifecycleScope]. + * + * @param state State on which the supplied [block] should be repeated, default is [State.RESUMED]. + * @param block Suspend function which should be launched in [androidx.lifecycle.LifecycleOwner.repeatOnLifecycle]. + */ +internal fun LifecycleOwner.repeatOnOwnerLifecycle( + state: State = State.RESUMED, + block: suspend CoroutineScope.() -> Unit, +) { + lifecycleScope.launch { + repeatOnLifecycle(state, block) + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/JsonElementExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/JsonElementExt.kt new file mode 100644 index 00000000..10d3fbc9 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/JsonElementExt.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.util + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal fun Map<*, *>.toJsonElement(): JsonElement { + val map: MutableMap = mutableMapOf() + this.forEach { + val key = it.key as? String ?: return@forEach + val value = it.value ?: return@forEach + when(value) { + is Map<*, *> -> map[key] = value.toJsonElement() + is List<*> -> map[key] = value.toJsonElement() + else -> map[key] = JsonPrimitive(value.toString()) + } + } + return JsonObject(map) +} + +internal fun List<*>.toJsonElement(): JsonElement { + val list: MutableList = mutableListOf() + this.forEach { + val value = it ?: return@forEach + when(value) { + is Map<*, *> -> list.add(value.toJsonElement()) + is List<*> -> list.add(value.toJsonElement()) + else -> list.add(JsonPrimitive(value.toString())) + } + } + return JsonArray(list) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ToastExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ToastExt.kt new file mode 100644 index 00000000..85fd120a --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ToastExt.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.util + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes + +internal fun Context.showToast( + @StringRes stringSrc: Int, + duration: Int = Toast.LENGTH_SHORT, +) { + Toast.makeText(this, stringSrc, duration).show() +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/UriExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/UriExt.kt new file mode 100644 index 00000000..02b0270d --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/UriExt.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021-2024. NICE Ltd. All rights reserved. + * + * Licensed under the NICE License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/nice-devone/nice-cxone-mobile-sdk-android/blob/main/LICENSE + * + * TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE CXONE MOBILE SDK IS PROVIDED ON + * AN “AS IS” BASIS. NICE HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS + * OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. + */ + +package com.nice.cxonechat.ui.util + +import android.net.Uri +import java.util.UUID + +internal fun Uri.parseThreadDeeplink(): Result = runCatching { + val threadIdString = getQueryParameter("idOnExternalPlatform") + require(!threadIdString.isNullOrEmpty()) { "Invalid threadId in $this" } + UUID.fromString(threadIdString) +} diff --git a/chat-sdk-ui/src/main/res/layout/activity_main.xml b/chat-sdk-ui/src/main/res/layout/activity_main.xml deleted file mode 100644 index 121d29a1..00000000 --- a/chat-sdk-ui/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - diff --git a/chat-sdk-ui/src/main/res/layout/custom_snack_bar.xml b/chat-sdk-ui/src/main/res/layout/custom_snack_bar.xml deleted file mode 100644 index f64ad626..00000000 --- a/chat-sdk-ui/src/main/res/layout/custom_snack_bar.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/chat-sdk-ui/src/main/res/layout/fragment_chat_thread.xml b/chat-sdk-ui/src/main/res/layout/fragment_chat_thread.xml deleted file mode 100644 index 044cd94d..00000000 --- a/chat-sdk-ui/src/main/res/layout/fragment_chat_thread.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - diff --git a/chat-sdk-ui/src/main/res/menu/default_menu.xml b/chat-sdk-ui/src/main/res/menu/default_menu.xml deleted file mode 100644 index c3219e8f..00000000 --- a/chat-sdk-ui/src/main/res/menu/default_menu.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - diff --git a/chat-sdk-ui/src/main/res/navigation/chat.xml b/chat-sdk-ui/src/main/res/navigation/chat.xml deleted file mode 100644 index 64056ffc..00000000 --- a/chat-sdk-ui/src/main/res/navigation/chat.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - diff --git a/chat-sdk-ui/src/main/res/navigation/offline.xml b/chat-sdk-ui/src/main/res/navigation/offline.xml deleted file mode 100644 index c65fe804..00000000 --- a/chat-sdk-ui/src/main/res/navigation/offline.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - diff --git a/chat-sdk-ui/src/main/res/navigation/threads.xml b/chat-sdk-ui/src/main/res/navigation/threads.xml deleted file mode 100644 index 0503ab0b..00000000 --- a/chat-sdk-ui/src/main/res/navigation/threads.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - diff --git a/chat-sdk-ui/src/main/res/values/strings.xml b/chat-sdk-ui/src/main/res/values/strings.xml index 5b5c7be4..69c3d4b4 100644 --- a/chat-sdk-ui/src/main/res/values/strings.xml +++ b/chat-sdk-ui/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Information OK Cancel + Dismiss Change custom fields Set name Send @@ -83,12 +84,9 @@ Send audio message SDK connecting - @string/cancel SDK connected SDK unexpectedly disconnected Reconnect - SDK closed - Restart SDK has encountered error Close Chat @@ -112,11 +110,9 @@ Image preview Update Thread Name Enter Thread Name - Thread Video preview Preparing SDK Agent - Me Read Received Share attachment %1$s +%2$d others diff --git a/cxone-detekt-rules/build.gradle.kts b/cxone-detekt-rules/build.gradle.kts index f32ccc07..98bb2e09 100644 --- a/cxone-detekt-rules/build.gradle.kts +++ b/cxone-detekt-rules/build.gradle.kts @@ -13,7 +13,7 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlin.jvm) @@ -47,8 +47,10 @@ java { toolchain.languageVersion.set(JavaLanguageVersion.of(17)) } -tasks.withType() { - kotlinOptions.jvmTarget = "17" +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } } tasks.withType().configureEach { diff --git a/docs/case-studies.md b/docs/case-studies.md index 69db2748..bdc47c7a 100644 --- a/docs/case-studies.md +++ b/docs/case-studies.md @@ -10,3 +10,5 @@ Here's a selection of simplified, but fully functional, use cases to get you sta - [Push notifications](cs-push-notifications.md) - [Rich Messages](cs-rich-messages.md) - [Analytics](cs-analytics.md) +- [Setting customer id](cs-customer-id.md) +- [Logging](cs-logging.md) diff --git a/docs/cs-analytics.md b/docs/cs-analytics.md index a2a18d27..688cf7c9 100644 --- a/docs/cs-analytics.md +++ b/docs/cs-analytics.md @@ -1,8 +1,9 @@ # Case Study: Analytics -CXone backend provides WFA (WorkFlow automation) functionality which relies on Chat SDK reporting of analytic events. +CXone backend provides WFA (WorkFlow automation) functionality which relies on Chat SDK reporting of analytic events which serve as triggers for the automation. +More information about WFA can be found in the [CXone documentation](https://help.nice-incontact.com/content/acd/digital/chat/workflowautomation.htm). -## Analytics events +## Supported Analytics events - **ChatWindowOpenEvent** - Specific Chat screen (conversation) has been opened @@ -63,12 +64,12 @@ class ChatViewModel : ViewModel() { Events `events.pageView()` and `events.pageViewEnded()` can help you with tracking customer visits within your application. Also, these events are used for automatic reporting of time spent on page by the user. -> ⚠️ Important: +> [!IMPORTANT] > Integrator must handle entering background on its own, the SDK does not handle this behavior. > Implement lifecycle observer which will report `pageView()` event for `Lifecycle.Event.ON_START` and `pageViewEnded()` > for `Lifecycle.Event.ON_STOP`. -> ⚠️ Important: +> [!IMPORTANT] > Thread list, chat transcript, etc. should not be generating page view events. For tracing chatting with the agent, > the SDK includes the `chatWindowOpen()` method. @@ -78,3 +79,6 @@ Proactive events are used for evaluation of user flow when they are presented wi When the proactive action is presented to the user, the integration should report `events.proactiveActionDisplay(action.metadata)` and when user interacts with it the application should report `events.proactiveActionClick(action.metadata)`. Reporting of success and failure is left to interpretation of integrators. + +In current version the SDK only supports Popup Box which requires the integration to implement `ChatActionHandler.OnPopupActionListener` interface and register it via the `ChatActionHandler.onPopup` method. +Other details can be found in the [Chat SDK documentation](https://help.nice-incontact.com/content/acd/digital/guide/guideactions/mobileapplicationpopupbox.htm?tocpath=CXone%20Guide%7CCXone%20Guide%7CCreate%20Engagement%20Rules%7CLegacy%20Engagement%20Actions%7C_____6#MobileApplicationPopupBox). diff --git a/docs/cs-coroutines.md b/docs/cs-coroutines.md index cb24fcfe..a45dda02 100644 --- a/docs/cs-coroutines.md +++ b/docs/cs-coroutines.md @@ -3,6 +3,8 @@ Coroutines are widely accepted framework in the Android space, therefore we would like to show you how to implement some extensions atop of CXone Chat SDK. +The following examples are based on source code in the Chat SDK UI module, which can be found [here](../chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main). + ## Libraries Additional dependencies required to run these samples. All samples are validated for version @@ -10,8 +12,8 @@ described in the `dependencies` block, Major update revisions may vary in syntax ```groovy dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' - runtimeOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0" + runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" } ``` @@ -49,7 +51,8 @@ val ChatThreadHandler.flow This is an extension for `ChatInstanceProvider` described [here][cs-instance-holder]. -> ⚠️ We do not necessarily believe that this is the "go-to" implementation for indicating that the +> [!WARNING] +> We do not necessarily believe that this is the "go-to" implementation for indicating that the > chat is ready. Implement callbacks to your `ChatInstanceProvider`, if necessary. Though this is > useful for demonstration or PoC purposes. diff --git a/docs/cs-instance-holder.md b/docs/cs-instance-holder.md index 89f1b894..290e18ca 100644 --- a/docs/cs-instance-holder.md +++ b/docs/cs-instance-holder.md @@ -1,21 +1,72 @@ # Case Study: Instance Holder -This CS demonstrates how to correctly handle process transitions with your Chat instance. There is -obviously some leeway on how it can be implemented. If your application doesn't use any of these +This CS demonstrates how to correctly handle process transitions with your Chat instance. +The first step in using the SDK is to get a chat instance, and the examples below walk you through that. +The `ChatInstanceProvider` operates through a singleton pattern, so the implementor doesn’t have to worry +about managing their chat instances. If your application doesn't use any of these components, feel free to use whatever tooling you're comfortable with. Just note that this example follows "Google Suggested" practices. +## Step-by-step: +The usage of `ChatInstanceProvider` consists of following steps: + 1. Creating instance of `ChatInstanceProvider` & binding the `ChatInstanceProvider` creation to the application lifecycle. + 2. Obtaining the chat instance & binding the chat instance to the activity lifecycle. + 3. Implementing the `ChatStateListener` interface to handle chat state changes. + 4. Using chat instance. + ## Libraries +For this case study we will need following extra libraries, apart from the Chat SDK. + ```groovy dependencies { implementation "androidx.lifecycle:lifecycle-common:2.6.2" implementation "androidx.startup:startup-runtime:1.1.1" } ``` +## Creating & binding the `ChatInstanceProvider` + +Let's create initialize the `ChatInstanceProvider` with the application process, +using the `androidx.startup` library. + +### `ChatInitializer.kt` +```kotlin +class ChatInitializer : Initializer { + override fun create(context: Context) = ChatInstanceProvider.create( + context, + BuildConfig.CXOneRegion.let(CXOneEnvironment::valueOf).value, + BuildConfig.CXOneBrandId, + BuildConfig.CXOneChannelId + ) + + override fun dependencies() = emptyList() +} +``` + +### `AndroidManifest.xml` + +```xml + + + + + + + + +``` -### `ChatInstanceProvider` +### Anywhere else in your code +```kotlin +fun interactWithChat() { + val chat = ChatInstanceProvider.get().chat ?: return +} +``` + +## Obtain Chat instance and bind it to the activity lifecycle You are strongly encouraged to bind chat instance to your activity's lifecycle. Chat is always directly bound to a network socket that listens to messages. Failure to dispose the connection after leaving the activity, leaks the connection. @@ -34,6 +85,31 @@ The CXone Chat SDK provides `com.nice.cxonechat.ChatInstanceProvider` to help wi > Note that before launching this activity (which automatically initializes chat) you should ask for > necessary permissions regarding EULA, GDPR or similar policies. +```kotlin +class ChatActivity : AppCompatActivity(R.layout.activity_chat) { + + private var chatInstanceProvider: ChatInstanceProvider? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + chatInstanceProvider = ChatInstanceProvider.get() + } + + override fun onResume() { + super.onResume() + } + + override fun onPause() { + super.onPause() + + chatInstanceProvider.stop() + } +} +``` + +## Implementing `ChatStateListener` +We can now expand our previous implementation of `ChatActivity` to include the `ChatStateListener` interface. + ```kotlin class ChatActivity : AppCompatActivity(R.layout.activity_chat), ChatInstanceProvider.Listener { @@ -95,42 +171,18 @@ class ChatActivity : AppCompatActivity(R.layout.activity_chat), ChatInstanceProv > Note that interactions with chat instance (e.g. sending of a message) > while it is not connected can be lost if the connection is not reestablished. -### `ChatInitializer.kt` - -Let the chat initialize with a process, using the `androidx.startup` library. - -```kotlin -class ChatInitializer : Initializer { - override fun create(context: Context) = ChatInstanceProvider.create( - context, - BuildConfig.CXOneRegion.let(CXOneEnvironment::valueOf).value, - BuildConfig.CXOneBrandId, - BuildConfig.CXOneChannelId - ) - - override fun dependencies() = emptyList() -} -``` - -### `AndroidManifest.xml` - -```xml - - - - - - - - -``` - -### Anywhere else in your code - -```kotlin -fun interactWithChat() { - val chat = ChatInstanceProvider.get().chat ?: return -} -``` +## Next Steps +After obtaining your chat instance, you should perform the following steps: + 1. Preparing and Connecting + - This is already outlined in the [Implementing `ChatStateListener`](#implementing-chatstatelistener) section. + - **Prepare** - This method should be called early, when the app is started. It allows the SDK to fetch configuration and allows usage of the SDK Analytics. + - **Connect** - This method should be called when the user is ready to start chatting. It establishes an active connection to the chat server. + - Waiting for **Ready** state - It is recommended to wait for the Ready state before starting to interact with the chat instance. This allows the SDK to perform necessary calls to the server and prepare the chat instance for the interaction. + 2. Creating a Thread + - See either [Case Study Single Thread](cs-single-thread.md), [Case Study Multiple Threads](cs-multi-thread.md) or [Case Study Live Chat](cs-live-chat.md) since implementation of this step greatly depends on your channel configuration. + 3. Handling Messages + - This part is outlined in the [implementation.md](implementation.md) document. + 4. Push notification support + - While push notifications are not mandatory, it is a recommended that your application takes advantage of them. For more information, refer to the [Case Study Push Notifications](cs-push-notifications.md) document. + +For more detailed information, refer to the [implementation.md](implementation.md) document which explains the differences between `ChatInstanceProvider` and `ChatBuilder`. diff --git a/docs/cs-live-chat.md b/docs/cs-live-chat.md index 3941d0ae..df23d6c1 100644 --- a/docs/cs-live-chat.md +++ b/docs/cs-live-chat.md @@ -18,6 +18,17 @@ or Chat SDK reinitialization the transcript history is cleared for the client. As part of the connect operation it will attempt to recover an existing thread, if it's `canAddMoreMessages` property is true, indicating the thread is still active, otherwise, a new thread must be created. +> [!IMPORTANT] +> It is important for proper functioning of the Live Chat that the channel has gone through the required [setup](https://help.nice-incontact.com/content/acd/digital/chat/setuplivechat.htm) +> including the [routing setup](https://help.nice-incontact.com/content/acd/digital/chat/setuplivechat.htm#ConfigureRoutingandQueues) + +> [!NOTE] +> For testing of the Live Chat channel one of the agents assigned to the routing queue **must be online**, otherwise the whole channel is considered **offline** and will be reported +> as such. +> Once the agent comes online, there can be **several minutes delay** before the channel is reported as online due to the design of the backend system. +> The SDK caches the channel state (in-memory) for **one minute** and will not attempt to reconnect to the server until the cache expires and the channel is still reported as online. +> Expectation is that the agents have set operation hours for the channel (e.g.: 9 AM - 5 PM) and the channel is not available outside of these hours and therefore the channel state won't be updated often. + ## Example >This case study builds on the information in [CS: Instance Holder][cs-instance-holder], so you should familiarize yourself diff --git a/docs/cs-logging.md b/docs/cs-logging.md new file mode 100644 index 00000000..d0227ffc --- /dev/null +++ b/docs/cs-logging.md @@ -0,0 +1,123 @@ +# Case Study: Logging + +This CS describes how to set up logging for the Chat SDK and details about the logging library +in case the integrating application would want to use it itself. + +## Quick Android console logging + +> [!WARNING] +> The Chat SDK at the moment performs only minimal redaction of sensitive data in the logs. +> Application should always assume that the log messages may contain sensitive data and that those messages shouldn't be +> shared in publicly accessible log channels (e.g. Android console aka logcat) or that they need to be redacted before sharing! +> +> For this reason the Chat SDK is using the `LoggerNoop` logger by default, which doesn't log anything. + +The Chat SDK already depends on the API artifact of the Logging library (com.nice.cxone:logger), +so only the thing which has to be done is to add a dependency on the Android implementation: + +```groovy +dependencies { + implementation "com.nice.cxone:logger-android:latest" +} +``` + +and pass the instance of the `LoggerAndroid` to the `ChatSdk`: + +```kotlin + fun createChatInstance() : ChatInstanceProvider { + return ChatInstanceProvider.create( + configuration = getSdkConfiguration(), + authorization = getSdkAuthorization(), + userName = getUserName(), + developmentMode = false, // or true during development when you need to see internal SDK logs + logger = LoggerAndroid("CXoneChat") + ) + } +``` +### Development mode +The `developmentMode` parameter is used to enable or disable method logging in the SDK. +If the mode is disabled only the websocket messages are logged. + +## Logging to multiple destinations +For this purpose you can use `ProxyLogger` class which allows you to log messages to multiple loggers. +```kotlin + fun createChatInstance() : ChatInstanceProvider { + return ChatInstanceProvider.create( + ..., // Other required parameters are omitted for brevity + logger = ProxyLogger( + LoggerAndroid("CXoneChat"), + FileLogger("CXoneChat-log.txt") + ) + ) + } +``` +This can be useful for example when you want to log error messages from the SDK to your reporting system. + +## Detailed description of the Logging library + +### Logger + +This very simple library provides a simple logging interface that is used by the Chat SDK +to log messages. +It doesn't depend on Android and can be used in any Kotlin (or Java) project. + +It can be added to your project by adding the following dependency: + +```groovy +dependencies { + implementation "com.nice.cxone:logger:latest" +} +``` + +#### Logger interface + +Core of this library is the `Logger` interface: + +```kotlin +interface Logger { + fun log(level: Level, message: String, throwable: Throwable? = null) +} +``` +The library provides default no-op singleton (**object**) implementation of this interface called `LoggerNoop`. + +#### LoggerScope + +Atop of this interface, there is a `LoggerScope` which provides scope metadata for the logged messages, +with it's default (private) implementation `NamedScope` which prefixes all messages with the scope name +e.g.: `NamedScope` with name `ChatSdk` would log message `connect()` as `[ChatSDK] connect()` + +The `LoggerScope` also provides two extension functions for creating sub-scopes: +* `LoggerScope.scope(name: String, body: LoggerScope.() -> T)` - creates a new scope which combines the sub-scope name with parent e.g.: `parentScope/subScope` +* `LoggerScope.timesScope(name: String, body: () -> T): T` - does the same as `scope` but also measures the time spent executing the `body` lambda + + +#### Extension methods +These methods can be used to conveniently log messages with different levels: + +```kotlin +class MyClass(val logger: Logger) { + + fun exampleMethod() { + logger.verbose("This is a verbose level message") + logger.debug("This is a debug level message") + logger.info("This is an info level message") + logger.warn("This is a warning level message") + logger.error("This is an error level message") + } +} +``` + +#### Level +The `Level` is a sealed class with 6 predefined levels for logging and a custom level which +can be used for a fine-tuned logging. +The `Level` implements `Comparable` so it can be used for easy filtering of log messages even +with the custom levels. + +#### ProxyLogger +The `ProxyLogger` is a wrapping logger implementation which allows you to log messages to multiple loggers, +it doesn't produce any output on its own. + +### LoggerAndroid +This library provides a simple implementation `Logger` interface which logs messages to the Android console (logcat) +with appropriate log level and automatically chunks large messages to avoid the 4k limit of the logcat. +It also logs any exceptions passed to the `log` method. diff --git a/docs/cs-multi-thread.md b/docs/cs-multi-thread.md index 4df6116a..6b5397bc 100644 --- a/docs/cs-multi-thread.md +++ b/docs/cs-multi-thread.md @@ -29,62 +29,11 @@ class ChatAllConversationsViewModel( private val chat = ChatInstanceProvider.get().chat.let(::requireNotNull) private val handlerThreads = chat.threads() - private val handlerFields = chat.fields() - private val handlerEvents = chat.events() private val cancellable = handlerThreads.threads { threads = it // notify ui }.also { handlerThreads.refresh() } - init { - FirebaseMessaging.getInstance().token.addOnSuccessListener { - chat.setDeviceToken(it) - } - val configuration = chat.configuration - val deviceInfo = configuration.customerCustomFields - .filterIsInstance(FieldDefinition.Text::class.java) - .mapNotNull { - when (val id: String = it.fieldId) { - "device-oem" -> id to Build.MANUFACTURER - "device-model" -> id to Build.MODEL - "device-os" -> id to "Android" - "device-version" -> id to Build.VERSION.SDK_INT.toString() - else -> null - } - }.toMap() - handlerFields.add(deviceInfo) - } - - /** - * send a page viewed event to the server. - * - * onPageView and onPageViewEnded should be invoked from each page (i.e., fragment - * or activity) in your application, as in: - * - * OnLifecycleEvent { _, event -> - * when (event) { - * ON_RESUME -> viewModel.onPageView(pageTitle, pageUrl) - * ON_PAUSE -> viewModel.onPageViewEnded(pageTitle, pageUrl) - * else -> Ignored - * } - * } - */ - fun onPageView(title: String, url: String) { - handlerEvents().pageView(title, url) - } - - /** - * Send a time spent on page event to the server to "close" a corresponding - * page view event. - */ - fun onPageViewEnded(title: String, url: String) { - handlerEvents().pageViewEnded(title, url) - } - - fun onConversion(type: String, value: Number) { - handlerEvents.conversion(type, value) - } - fun onClickThread(thread: ChatThread) { navigator.toDetail(thread) } @@ -115,9 +64,8 @@ class ChatAllConversationViewModel( private val handlerMessage = handlerThread.messages() private val handlerAction = handlerThread.actions() private val handlerEvents = handlerThread.events() - private val handlerFields = handlerThread.fields() private val cancellable = handlerThread.get { - if (isInForeground) handlerEvents.trigger(MarkThreadReadEvent) + if (isInForeground) handlerEvents.markThreadRead() // notify ui that ::thread has changed }.also { handlerThread.refresh() } private val sentListener = MessageListener(WeakReference(this)) @@ -126,19 +74,6 @@ class ChatAllConversationViewModel( val thread get() = handlerThread.get() val messagesSent = mutableSetOf() - init { - val customFields = chat.configuration.contactCustomFields - .filterIsInstance(FieldDefinition.Text::class.java) - .mapNotNull { - when (val id: String = it.fieldId) { - "last-contact" -> id to "2005-08-25T11:45:89.187Z" - "flag-disrespectful" -> id to "true" - else -> null - } - }.toMap() - handlerFields.add(customFields) - } - fun setName(name: String) { handlerThread.setName(name) } @@ -147,16 +82,8 @@ class ChatAllConversationViewModel( handlerAction.onPopup(listener) } - fun onTypingStart() { - handlerEvents.trigger(TypingStartEvent) - } - - fun onTypingStop() { - handlerEvents.trigger(TypingEndEvent) - } - fun onClickArchive() { - handlerEvents.trigger(ArchiveThreadEvent) + handlerThread.archive() } fun onEndReached() { diff --git a/docs/cs-push-notifications.md b/docs/cs-push-notifications.md index 4b666106..1be74574 100644 --- a/docs/cs-push-notifications.md +++ b/docs/cs-push-notifications.md @@ -117,5 +117,5 @@ by passing `null` to the `setDeviceToken()` method. [Firebase Cloud Messaging]: https://firebase.google.com/docs/cloud-messaging [Set up a Firebase Cloud Messaging client app on Android]: https://firebase.google.com/docs/cloud-messaging/android/client -[Set Up]: https://help.nice-incontact.com/content/acd/digital/mobilesdk/android/getstartedandroid.htm?tocpath=MobileSDK|CXone Mobile SDK|Get Started with CXone Mobile SDK for Android|_____0# +[Set Up]: https://help.nice-incontact.com/content/acd/digital/mobilesdk/setupadvancedfeatures.htm?tocpath=Digital%20Experience%7CDigital%20Experience%20%7CDigital%20Channels%7CChat%20Channels%7CCXone%20Mobile%20SDK%7C_____1#PushNotifications [Firebase guide - Set up the SDK]: https://firebase.google.com/docs/cloud-messaging/android/client#set_up_the_sdk diff --git a/docs/cs-rich-messages.md b/docs/cs-rich-messages.md index b5d19a79..47a44b43 100644 --- a/docs/cs-rich-messages.md +++ b/docs/cs-rich-messages.md @@ -4,10 +4,6 @@ Rich messaging is an optional part of Chat SDK. They are used to deliver rich message content to the user, beyond the capabilities of a simple message or message with an attachment. -### Message types -The Chat SDK supports two general types of rich messages -1. TORM messages — a set of message components shared in DTO across multiple channels (Facebook, WhatsApp, Chat) - ### Postback Rich messages may contain a call-to-action (or action for short), which may contain an optional `postback` value. @@ -16,12 +12,40 @@ The response must be a message which contains the label and post back value from The reason for this is that the postback is used by automation (triggers/bots) to recognize the specific choice from the rich message from an ordinary customer message with content matching the label of possible action in a rich message. -> Warning: If you don't provide TORM `postback` value, the chat-bot integration may not work correctly! +> [!WARNING] +> If you don't provide TORM `postback` value, the chat-bot integration may not work correctly! ## TORM - Truly Omnichannel Rich Messaging -* Quick Reply - Message with a list of actions. Only one action can be selected from provided options, and once selected it should be made inactive. It is the responsibility of integrating application to enforce this constraint. -* List Picker - Message with title, body and list of actions (possibly with images). Any action from the list can be selected multiple times. -* Rich Link - Message with title, optional image and Uri link. The link can be a deeplink or an ordinary url. +A set of message components shared in DTO across multiple channels (Facebook, WhatsApp, Chat). + +### Supported TORM types: + +* Quick Reply + - Message with a list of actions. Only one action can be selected from provided options, and once selected it should be made inactive. It is the responsibility of integrating application to enforce this constraint. + - Contains: + - title + - list of actions +* List Picker + - Message with title, body and list of actions (possibly with images). Any action from the list can be selected multiple times. + - Contains: + - title + - text + - list of actions +* Rich Link + - Message with title, optional image and Uri link. The link can be a deeplink or an ordinary url. + - Contains: + - title + - Media object + - url link + +All of the TORM message contain fields common for all message types (text messages and attachment messages): +- id +- threadId +- createdAt +- direction +- _optional_ author +- _optional_ list of attachments +- _optional_ fallbackText ```kotlin /** @@ -38,7 +62,7 @@ interface Action { interface ReplyButton : Action { /** Postback to be sent as part of the [OutboundMessage] if the button is selected. */ val postback: String? - ... + // ... } } ``` @@ -55,7 +79,7 @@ class ChatAllConversationViewModel( private val handlerThread = handlerThreads.thread(thread) private val handlerMessage = handlerThread.messages() - ... + // ... fun send(text: String, postback: String) { handlerMessage.send(OutboundMessage(text, postback), sentListener) diff --git a/docs/implementation.md b/docs/implementation.md index a7ad665d..d8d76018 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1,19 +1,33 @@ -# Integrator's guide - -This document will guide you through the necessary steps to integrate CXone Chat SDK application of -your own. - -> Please follow the steps diligently, that will ensure you're using the SDK in a correct way. -> If you're unsure about any method and what causes it may incur, -> consult the documentation provided with the clone of your public SDK. -> That would typically be a bundled JAR or a link to current HTML documentation. - -All examples are written in Kotlin, though you might use Java with this SDK. The SDK version you've -been provided is heavily obfuscated to discourage you from using internal APIs. - -> We strongly urge you to not use reflection for any of CXone SDK classes. +# Integrator's Guide + +## Table of Contents +1. [Introduction](#introduction) +2. [Proguard / R8](#proguard--r8) +3. [Setting Up](#setting-up) +4. [Startup](#startup) +5. [Configuration](#configuration) +6. [Push Notification Tokens](#push-notification-tokens) +7. [Threads](#threads) +8. [Thread](#thread) +9. [Message States](#message-states) +10. [Manual Username Update](#manual-username-update) +11. [Custom Fields](#custom-fields) +12. [Global Events](#global-events) + +## Introduction +This document will guide you through the necessary steps to integrate CXone Chat SDK into your application. + +> [!NOTE] +> Please follow the steps diligently to ensure you're using the SDK correctly. +> If you're unsure about any method, consult the documentation provided with the SDK. + +All examples are written in Kotlin, though you might use Java with this SDK. The SDK version you've been provided is heavily obfuscated to discourage you from using internal APIs. + +> [!IMPORTANT] +> We strongly urge you not to use reflection for any of CXone SDK classes. > Your code **will** break from release to release. +> [!NOTE] > Note that in every example, the instance of any given Handler is created at most once. > Make sure to follow suit. @@ -27,45 +41,41 @@ They are bundled with the chat-sdk-code aar and are provided automatically with Ensure you've received your **Region**, **Brand ID** and **Channel ID**. -- Region - - Is referenced in code as "environment" - - Is typically closest to your main deployment region - - Is one of (but not limited to): - - `NA1`, `EU1`, `AU1`, `CA1`, `UK1`, `JP1` - - List may grow and may not be documented here, consult code reference for more clarity. -- Brand ID - - Is typically 4 digit integer -- Channel ID - - Is typically a UUID string prefixed with "chat_" +- **Region**: Referenced in code as "environment". Typically closest to your main deployment region. + - Examples: `NA1`, `EU1`, `AU1`, `CA1`, `UK1`, `JP1` +- **Brand ID**: Typically a 4-digit integer. +- **Channel ID**: Typically a UUID string prefixed with "chat_". Once you got all of these data, you may proceed. +> [!NOTE] > If you're unsure where to get these values, you should consult your CXone representative or local > managers depending on your company structure. -### Startup +## Startup -First you need to obtain `Chat` instance. You can achieve that through our `ChatBuilder` +First you need to obtain a `Chat` instance. You can achieve that through our `ChatBuilder` or you can use `ChatInstanceProvider` which is covered [here](cs-instance-holder.md). +We recommend the use of `ChatInstanceProvider` since it provides state tracking for the chat instance, but to cover both cases we will show you how to use the `ChatBuilder` in the following example. ```kotlin -val config = SocketFactoryConfiguration( - CXOneEnvironment.YourRegion, - yourBrandId, - yourChannelId -) -val myChatStateListener = object : ChatStateListener() { - override fun onReady() { - // TODO - Chat instance is ready for usage by the consumer, use Chat instance for chat - } -} -cancellable = ChatBuilder(context, config) - .setDevelopmentMode(BuildConfig.DEBUG) - .setAuthorization(yourAuthorization) // (1) - .setUserName("firstName", "lastName") // (2) - .setChatStateListener(myChatStateListener) - .build { chat -> - // TODO save chat instance + val config = SocketFactoryConfiguration( + CXOneEnvironment.YourRegion, + yourBrandId, + yourChannelId + ) + val myChatStateListener = object : ChatStateListener() { + override fun onReady() { + // TODO - Chat instance is ready for usage by the consumer, use Chat instance for chat + } } + val cancellable = ChatBuilder(context, config) + .setDevelopmentMode(BuildConfig.DEBUG) // Development mode shouldn't be enabled in production + .setAuthorization(yourAuthorization) // (1) + .setUserName("firstName", "lastName") // (2) + .setChatStateListener(myChatStateListener) + .build { chat -> + // TODO save chat instance + } ``` - (1) Authorization @@ -78,16 +88,16 @@ cancellable = ChatBuilder(context, config) - If you are using a manual username setup, please follow instructions on updating the username (if it can change in your application). -> ℹ️ +> [!NOTE] > The `build` method asynchronously creates an instance of Chat which is ready for analytics usage, for chat use-case it > needs to be connected. > Chat will start the asynchronous connection attempt once the `Chat.connect()` method is called. > -> In case of connection error, the application will be notified and it will have to schedule a connection retry attempt. +> In case of connection error, the application will be notified, and it will have to schedule a connection retry attempt. > Application can cancel both the build and connection process according to its requirements via `Cancellable` instance > returned from the `build` and `connect` method calls. -> ⚠️ Important note +> [!IMPORTANT] > In case the startup was not successful for you and `build` method did not return the `Chat` > instance, be sure to check your configuration as server might have rejected the request. Read the > documentation for `build` method for more clarity on the subject. @@ -98,15 +108,19 @@ Now you can use the CXone Chat SDK for sending of analytics events (which are us If you also need to activate the chat, you will need to connect it to backend and let Chat perform basic preparation of the instance. -First you need to inform `Chat` instance that it should connect to backend by calling `chat.connect`. -Once it is connected the `Chat` instance will call the supplied `ChatStateListener.onConnected` callback. -At this moment the `Chat` has established socket connection with backend and it will start final background +1. First you need to inform `Chat` instance that it should connect to backend by calling `chat.connect`. +2. Once it is connected the `Chat` instance will call the supplied `ChatStateListener.onConnected` callback. +At this moment the `Chat` has established socket connection with backend, and it will start final background tasks to fully prepare instance for usage (retrieval of the thread in single-thread mode or thread list in the -multi-thread mode). `Chat` will inform that is fully ready by calling the `ChatStateListener.onReady` callback. +multi-thread mode). +3. `Chat` will inform that is fully ready by calling the `ChatStateListener.onReady` callback. Great! Now you're ready to use the CXone Chat SDK. -> ⚠️ Important note +> [!IMPORTANT] +> It is recommended to wait for the `ChatStateListener.onReady` callback before using the chat instance. + +> [!IMPORTANT] > Chat instance maintains open socket connection to backend, until `chat.close()` is called, or the > application process is terminated. > It is the responsibility of the integrating application, @@ -118,132 +132,27 @@ You can use the chat `configuration` property to support different UI/UX flows i preemptively verify that your assumptions about active chat configuration are correct. ```kotlin -val chat = MyChatInstanceProvider.chat ?: return -val chatConfiguration = chat.configuration + val chat = MyChatInstanceProvider.chat ?: return + val chatConfiguration = chat.configuration ``` ## Push Notification Tokens -This is obviously not required, but if you want your clients to receive push notifications, you need +This part of configuration is obviously not required, but if you want your clients to receive push notifications, you need to pass us the device push token. The push token can be, for most applications anyway, requested ad-hoc from firebase services, or is provided to you via BroadcastReceiver. Agent console also needs to have your application registered, which might involve using a Firebase -API key. - -```kotlin -val chat = MyChatInstanceProvide.chat ?: return -chat.setDeviceToken(yourDeviceToken) -``` - -## Custom Fields -Custom fields represent metadata about customers / users. -There are two types of these custom fields based on the context of the said data: -1. Customer Custom Fields — These are global for all chat threads and shouldn't contain information about one specific conversation. -2. Contact Custom Fields — These contain metadata relevant to the one specific chat thread / conversation. - -Custom fields are defined as part of the channel configuration (on backend), and only defined custom fields can be supplied to the chat instance. - -### Custom Field definitions -Custom field definitions can be accessed by using chat configuration instance. -Definition provides a `label` field which can hold human-readable label (or reference to string resource) and `fieldId` -which is used for adding of custom field values. - -#### Custom field types & expected values -| Type | Required value | Validations | -|-----------|----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------| -| Text | Any `String` | Value is validated against pattern `android.utils.Patterns.EMAIL_ADDRESS` is used if definition has flag `isEMail` set to true | -| Selector | `nodeId` from selected `SelectorNode` | Value must match id of one of the nodes | -| Hierarchy | `nodeId` from selected `HierarchyNode` | Value must must match id one of the **leaf** nodes | - -#### Adding custom fields using definitions -To create custom field entry for `Map`, which is used to add custom fields via `ChatFieldHandler`, -combine custom field definition `fieldId` with a value matching defined type. - -### Accessing current customer custom fields -Immutable collection of customer custom fields is available as field of the Chat instance - -```kotlin -val chat = MyChatInstanceProvide.chat ?: return -val customerCustomFields = chat.fields -``` - -> Note that the instance won't be modified if the custom fields get updated. -> It should be considered as a current snapshot. - -> Customer custom fields can get updated after the thread list refresh. - -> Note that it will always only contain custom fields which have a valid definition; legacy fields are filtered out. - -### Adding customer custom fields -You can append customer custom fields through Chat instance ChatFieldHandler handler. -You may be required to supply such values based on automatic flows attached to your chat channel -configuration. +credentials file. ```kotlin -fun addReferralUnknown() { val chat = MyChatInstanceProvide.chat ?: return - val customerCustomFields = chat.configuration.customerCustomFields.lookup("referral") as? FieldDefinition.Text ?: return - val customFields = mapOf(customerCustomFields.fieldId to "referral_unknown") - chat.customFields().add(customFields) -} + chat.setDeviceToken(yourDeviceToken) ``` -> Supplying the same key with different value, will overwrite the existing custom field. - -> Customer custom fields can be used for creation of an automatic welcome message or other automatic -> events. -> If you're unsure if these values will be required, you should consult your CXone representative -> or local managers depending on your company structure. - -## Global Events - -### Analytics events - -- **ChatWindowOpenEvent** - - Specific Chat page or screen (conversation) has been opened - - Correct reporting of this event is required for "Welcome message" automation. -- **ConversionEvent** - - The user was redirected from other media (link, etc…), made a purchase, read an article. - Anything your company has internally defined as a conversion. Generated by `ChatEventHandlerActions.conversion` -- **CustomVisitorEvent** - - Any event you may want to track. -- **PageViewEvent** - - User has visited a page in the host application. Generated by `ChatEventHandlerActions.pageView`. -- **TimeSpentOnPageEvent** - - User has left a page in the host application and time spent on the page. Automatically - generated by `ChatEventHandlerActions.pageViewEnded`. -- **ProactiveActionClickEvent** - - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionClick`. -- **ProactiveActionDisplayEvent** - - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionDisplay`. -- **ProactiveActionFailureEvent** - - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionFailure`. -- **ProactiveActionSuccessEvent** - - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionSuccess`. -- **TriggerEvent** - - Trigger an automation event by ID - -### Automatic analytics events - -- **VisitEvent** - - Defines the beginning of a visit, which is a sequence of -PageView events. - - Automatically generated by `ChatEventHandlerActions.viewPage` when there is no -current visit defined or when the last visit has been idle for more -than 30 minutes. -- **TimeSpentOnPageEvent** - - Reports time spent on the last page in seconds. - - Automatically generated by `ChatEventHandlerActions.viewPageEnded`. - -See analytics case study for information how to implement analytics events [here](cs-analytics.md) - -### Authorization events - -- **RefreshToken** - - Event notifying backend that authorization token has to be refreshed. +[Case Study Push Notifications](cs-push-notifications.md) offers more detail about this topic. ## Threads @@ -255,21 +164,23 @@ for some threading methods. First, you're required to fetch a Threads list. This is a list of all the threads you can access and have been created by this app's instance. -> ⚠️ Note that when retrieving a `Handler` you don't have to keep the instance since it is internally memoized. +> [!WARNING] +> Note that when retrieving a `Handler` you don't have to keep the instance since it is internally memoized. > Some methods may have effects directly on the given handler or parent handlers -```kotlin -val threadsHandler = chat.threads() // (1) -threadsHandler.threads { - // todo save the threads list - // update ui -} -``` + ```kotlin + val threadsHandler = chat.threads() // (1) + threadsHandler.threads { + // todo save the threads list + // update ui + } + ``` +> [!NOTE] > Note that we do not encourage a specific pattern as every application's code might be different. > Use your own expertise to determine how to update the UI and save the list of threads. -> ⚠️ Warning! +> [!WARNING] > Some listener methods return Cancellable effect. > You, are required to cancel the effect once it's no longer necessary. @@ -280,19 +191,32 @@ configuration is single or multi-threaded. Use a thread list to fetch the first instance in the list OR create a new thread. +> [!NOTE] > Note that a failure to follow these exact steps might cause an exception. Single Threaded > instances can have at most one thread. > Thrown exception can be of type `UnsupportedChannelConfigException` in case you are trying > to create second thread (or archive current one), > or it can be of type `MissingThreadListFetchException` when you have called `create` before the chat has signaled `ChatStateListener.onReady()`. +> If the channel configuration doesn't include pre-chat survey, the Chat will prepare an empty thread with no messages automatically. ```kotlin -val threads: List // stored somewhere -val thread = threads.firstOrNull() -val threadHandler = when (thread) { - null -> threadsHandler.create() - else -> threadsHandler.thread(thread) -} + val threadsHandler: ChatThreadsHandler = chat.threads() + + fun onThreadListUpdate(threads: List) { + val thread = threads.firstOrNull() + val threadHandler = when (thread) { + null -> displayPreChatSurvey(::threadsHandler.create) + else -> threadsHandler.thread(thread) + } + // Use the `threadHandler` to interact with the thread + } + + fun displayPreChatSurvey( + onSurveySubmit: (responses: List) -> Unit + ) { + // Display a dialog window with survey questions + // See [Pre-chat dynamic surveys](#pre-chat-dynamic-surveys) for more information + } ``` ### Multiple Threads @@ -302,13 +226,13 @@ and in this case, yes, the user is creating the threads. Not the application by itself. ```kotlin -fun onThreadClick(thread: ChatThread) { - val threadHandler = threadsHandler.thread(thread) -} - -fun onThreadCreateClick() { - val threadHandler = threadsHandler.create() -} + fun onThreadClick(thread: ChatThread) { + val threadHandler = threadsHandler.thread(thread) + } + + fun onThreadCreateClick() { + val threadHandler = threadsHandler.create() + } ``` ### Pre-chat dynamic surveys @@ -330,33 +254,32 @@ Survey questions should be presented to the user when he attempts to create a ne questions which are flagged as required has to be answered before a new thread can be created. ```kotlin -// Naive sample assuming single survey question of type Text -fun onThreadCreateClick(question: PreChatSurveyType.Text, answer: String) { - val responses = listOf( - PreChatSurveyResponse.Text(question, answer) - ) - val threadHandler = threadsHandler.create(responses) -} - + // Naive sample assuming single survey question of type Text + fun onThreadCreateClick(question: PreChatSurveyType.Text, answer: String) { + val responses = listOf( + PreChatSurveyResponse.Text(question, answer) + ) + val threadHandler = threadsHandler.create(responses) + } ``` -> ℹ️ +> [!NOTE] > Note that attempts to create thread without supplying responses to required survey questions will > cause an exception. - +> > Response sequence to pre-chat surveys is converted to custom field values and are sent with > a first thread message. -> Pre-chat survey is a subset of contact custom field definitions. --- Now that your configuration uses multiple or single threads you have obtained the Thread Handler. You can furthermore explore what can you with these objects and as long you follow the principles defined by warnings and notes above, you're generally good to go. -> ❓ If you have experience with browsing and using SDKs on your own, you can skip all the following +> [!TIP] +> If you have experience with browsing and using SDKs on your own, you can skip all the following > documentation. It's well documented in the code itself and can be used as reference. -> ℹ️ +> [!NOTE] > SDK is in-memory caching thread information for loaded threads (thread is considered loaded once > it's ChatThreadHandler has refreshed its information or user has sent at least one message). > The cache update is also propagated as thread list update. @@ -379,15 +302,16 @@ which include agent changes, messages and other updates. Another is to fetch the Thread changes typically include Metadata refresh, Agent swaps or new sent/received Messages. Might be extended in the future with more events and/or more reactivity. -> ⚠️ If you don't use this form of listening to Thread changes, effect-inducing +> [!WARNING] +> If you don't use this form of listening to Thread changes, effect-inducing > methods (`ChatThreadMessageHandler::loadMore` or similar) will have no effect when invoked. ```kotlin -threadHandler.get { - // TODO save current state and/or - // update ui -} -threadHandler.refresh() + threadHandler.get { + // TODO save current state and/or + // update ui + } + threadHandler.refresh() ``` #### Get current Thread state @@ -396,7 +320,7 @@ At any point you might request the handler to return the current Thread state. T every time `get {}` is called. Even if cancelled the **instance** will remember the latest value. ```kotlin -val thread = threadHandler.get() + val thread = threadHandler.get() ``` ### Update Thread name @@ -405,7 +329,7 @@ Changes the name for this particular thread. Can be user generated or even autom by your application. ```kotlin -threadHandler.setName("New Thread Name") + threadHandler.setName("New Thread Name") ``` ### Send a Message @@ -415,21 +339,21 @@ can be listened to, so UI reflects true state of any given message. Listeners ar optional. ```kotlin -val messageHandler = threadHandler.messages() -messageHandler.send("Hello world!") + val messageHandler = threadHandler.messages() + messageHandler.send("Hello world!") ``` … or the same with a listener: -```kotlin -messageHandler.send( - "Hello world!", - OnMessageTransferListener( - onProcessed = { /* notify UI */ }, - onSent = { /* notify UI */ } + ```kotlin + messageHandler.send( + "Hello world!", + OnMessageTransferListener( + onProcessed = { /* notify UI */ }, + onSent = { /* notify UI */ } + ) ) -) -``` + ``` ### Send a Message with a document @@ -440,17 +364,17 @@ differ from regular text Messages. > Identical reference is used to save bandwidth. ```kotlin -val descriptor = ContentDescriptor( - content = myPdfFileUri, - context = context, // any Android context for URI resolution - mimeType = "application/pdf", - fileName = "${UUID.randomUUID()}.pdf", - friendlyName = "my-awesome-pdf.pdf" -) -messageHandler.send(listOf(descriptor)) + val descriptor = ContentDescriptor( + content = myPdfFileUri, + context = context, // any Android context for URI resolution + mimeType = "application/pdf", + fileName = "${UUID.randomUUID()}.pdf", + friendlyName = "my-awesome-pdf.pdf" + ) + messageHandler.send(listOf(descriptor)) ``` -> ⚠️ Warning! +> [!WARNING] > Note that attachments passed as Uri will be entirely read into memory before being uploaded, so be careful your uploaded files are not too large. If you want to compress images, clip videos, it's a good time to do so before @@ -459,18 +383,20 @@ If you are compressing the images or videos before processing, it may be convenient to use the alternate constructor for `ContentDescriptor`: ```kotlin -val descriptor = ContentDescriptor( - content = myByteArray, - mimeType = "image/jpeg", - fileName = "${UUID.randomUUID()}.jpg", - friendlyName = "imageName.jpg" -) -messageHandler.send(listOf(descriptor)) + val descriptor = ContentDescriptor( + content = myByteArray, + mimeType = "image/jpeg", + fileName = "${UUID.randomUUID()}.jpg", + friendlyName = "imageName.jpg" + ) + messageHandler.send(listOf(descriptor)) ``` - -> ⚠️ Warning! + +> [!WARNING] > Note that such attachments will be stored in memory until they are uploaded to the server. -> Be careful how large files you'll upload. +> Be careful how large files you'll upload as they also have to conform to the file size limits enforced by the channel configuration. +> Channel configuration file size restrictions (in MB) can be retrieved from the `Chat` instance via `chat.configuration.fileRestrictions.allowedFileSize`. +> SDK enables `largeHeap` in its Android Manifest to allow loading of large files to memory for this purpose. In case of an issue during attachment upload, the application will be notified via `ChatStateListener.onChatRuntimeException`, if the optional `ChatStateListener` instance was supplied to the SDK. The `onChatRuntimeException` will be invoked with an @@ -482,19 +408,19 @@ attachment filename. Loading more messages requires `threadHandler.get {}` to be active. Updates are delivered through that callback. -```kotlin -messageHandler.loadMore() -``` + ```kotlin + messageHandler.loadMore() + ``` ### Update Thread-specific Custom Fields Add any key-value pairs to this Thread. They will be locally stored until next action is performed on the given thread. (ie. Sending a Message, …) -```kotlin -val fieldHandler = threadHandler.fields() -fieldHandler.add(mapOf("pet-preference" to "dog")) -``` + ```kotlin + val fieldHandler = threadHandler.fields() + fieldHandler.add(mapOf("pet-preference" to "dog")) + ``` ### Listen to Actions @@ -503,15 +429,15 @@ the `actionHandler`) as soon as possible. The SDK cannot guarantee that this callback will be called multiple times, nor it can guarantee that it will be called at least once. -```kotlin -val actionHandler = threadHandler.actions() -actionHandler.onPopup { variables, metadata -> - // save metadata for analytic events - // show popup with variables (should be ) -} -// when done with actions (typically after receiving the first one) -actionHandler.close() -``` + ```kotlin + val actionHandler = threadHandler.actions() + actionHandler.onPopup { variables, metadata -> + // save metadata for analytic events + // show popup with variables (should be ) + } + // when done with actions (typically after receiving the first one) + actionHandler.close() + ``` ### Send an Event @@ -519,14 +445,14 @@ You are permitted to send various types of events that are Thread specific. The be flexible, so we can add more events in the future relatively painlessly. Check the Available Events section below or browse object `com.nice.cxonechat.ChatThreadEventHandlerActions` for more info. -```kotlin -import com.nice.cxonechat.ChatThreadEventHandlerActions.archiveThread - -fun archiveThread(threadHandler: ChatThreadsHandler) { - val eventHandler = threadHandler.events() - eventHandler.archiveThread() -} -``` + ```kotlin + import com.nice.cxonechat.ChatThreadEventHandlerActions.archiveThread + + fun archiveThread(threadHandler: ChatThreadsHandler) { + val eventHandler = threadHandler.events() + eventHandler.archiveThread() + } + ``` #### Available Events @@ -553,21 +479,21 @@ You're free to use only some of those indications, or all of them. It's completely up to you. Though you might find a helpful description of how this state machine works. -#### Processed +### Sending All text messages are automatically processed as soon as they are sent -through `ChatThreadMessageHandler::send`. Messages with attachments (documents) are being processed -by sending them to a storage server first. Once successfully stored, they are marked processed. +through `ChatThreadMessageHandler::send`. Messages with attachments (documents) are processed +by sending them to a storage server first. Once successfully stored, they are reported as processed through listener. -This state might be beneficial for your users to see indeterminate progressbar as the message "is -being sent". +The state is never directly reported by the SDK for a message, since only succesfully sent messages, are reported. +UI implementation are encouraged to use this state to show the user that the message is being sent. -#### Sent +### Sent The Message reaches this state once it successfully leaves this device. If it doesn't leave this device, then the corresponding callback is never triggered. -#### Received +### Received The Received state is implicit. That means that if the `ChatThreadHandler::get` with callback returns the message in its list of messages, the message was received successfully by the server. @@ -575,13 +501,137 @@ the message in its list of messages, the message was received successfully by th If your new message is not received within a reasonable amount of time through this callback, offer your users to resend the message. -#### Read +### Read The agent has read the message and/or acted upon it. This indication is now part of the Message object received through aforementioned `ChatThreadHandler::get`. +### FailedToDeliver + +This state is not directly reported by the SDK, but can be inferred, +when listener callback `ChatStateListener.onChatRuntimeException` receives an instance of +`ServerCommunicationError` with message `SendingMessageFailed`. + +UI implementation are encouraged to use this state to show the user that the message failed to be sent. + ## Manual username update If you are not using OAuth user authentication and your application allows to change username in application, you will have to close current instance of chat and create a new instance of chat using the `ChatBuilder`. The updated username can be supplied to the builder before chat instance is created. + +## Custom Fields +Custom fields represent metadata about customers / users. +There are two types of these custom fields based on the context of the said data: +1. Customer Custom Fields — These are global for all chat threads and shouldn't contain information about one specific conversation. +2. Contact Custom Fields — These contain metadata relevant to the one specific chat thread / conversation. + +Custom fields are defined as part of the channel configuration (on backend), and only defined custom fields can be supplied to the chat instance. + +### Custom Field definitions +Custom field definitions can be accessed by using chat configuration instance. +Definition provides a `label` field which can hold human-readable label (or reference to string resource) and `fieldId` +which is used for adding of custom field values. + +#### Custom field types & expected values +| Type | Required value | Validations | +|-----------|----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------| +| Text | Any `String` | Value is validated against pattern `android.utils.Patterns.EMAIL_ADDRESS` is used if definition has flag `isEMail` set to true | +| Selector | `nodeId` from selected `SelectorNode` | Value must match id of one of the nodes | +| Hierarchy | `nodeId` from selected `HierarchyNode` | Value must must match id one of the **leaf** nodes | + +#### Adding custom fields using definitions +To create custom field entry for `Map`, which is used to add custom fields via `ChatFieldHandler`, +combine custom field definition `fieldId` with a value matching defined type. + +### Accessing current customer custom fields +Immutable collection of customer custom fields is available as field of the Chat instance + +```kotlin + val chat = MyChatInstanceProvide.chat ?: return + val customerCustomFields = chat.fields +``` + +> [!NOTE] +> Note that the instance won't be modified if the custom fields get updated. +> It should be considered as a current snapshot. + +> [!NOTE] +> Customer custom fields can get updated after the thread list refresh. + +> [!NOTE] +> Note that it will always only contain custom fields which have a valid definition; legacy fields are filtered out. + +### Adding customer custom fields +You can append customer custom fields through Chat instance ChatFieldHandler handler. +You may be required to supply such values based on automatic flows attached to your chat channel +configuration. + + ```kotlin + fun addReferralUnknown() { + val chat = MyChatInstanceProvide.chat ?: return + val customerCustomFields = chat.configuration.customerCustomFields.lookup("referral") as? FieldDefinition.Text ?: return + val customFields = mapOf(customerCustomFields.fieldId to "referral_unknown") + chat.customFields().add(customFields) + } + ``` + +> Supplying the same key with different value, will overwrite the existing custom field. + +> Customer custom fields can be used for creation of an automatic welcome message or other automatic +> events. +> If you're unsure if these values will be required, you should consult your CXone representative +> or local managers depending on your company structure. + +## Global Events + +### Analytics events + +- **ChatWindowOpenEvent** + - Specific Chat page or screen (conversation) has been opened + - Correct reporting of this event is required for "Welcome message" automation. +- **ConversionEvent** + - The user was redirected from other media (link, etc…), made a purchase, read an article. + Anything your company has internally defined as a conversion. Generated by `ChatEventHandlerActions.conversion` +- **CustomVisitorEvent** + - Any event you may want to track. +- **PageViewEvent** + - User has visited a page in the host application. Generated by `ChatEventHandlerActions.pageView`. +- **TimeSpentOnPageEvent** + - User has left a page in the host application and time spent on the page. Automatically + generated by `ChatEventHandlerActions.pageViewEnded`. +- **ProactiveActionClickEvent** + - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionClick`. +- **ProactiveActionDisplayEvent** + - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionDisplay`. +- **ProactiveActionFailureEvent** + - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionFailure`. +- **ProactiveActionSuccessEvent** + - Action regards to `ChatActionHandler::onPopup`. Generated by `ChatEventHandlerActions.proactiveActionSuccess`. +- **TriggerEvent** + - Trigger an automation event by ID + +### Automatic analytics events + +#### PageView triggered events +Following events are automatically generated by the SDK when the application triggers +` ChatEventHandler.pageView` and ` ChatEventHandler.pageViewEnded` methods. +These should be triggered for every page view by the user in the application. +What is a "page" is defined by the application, it can be a activity, a fragment, or a specific compose view. + +- **VisitEvent** + - Defines the beginning of a visit, which is a sequence of + PageView events. + - Automatically generated by ` ChatEventHandlerActions.pageView` method when there is no + current visit defined or when the last visit has been idle for more + than 30 minutes. +- **TimeSpentOnPageEvent** + - Reports time spent on the last page in seconds. + - Automatically generated by `ChatEventHandlerActions.pageViewEnded`. + +See analytics case study for information how to implement analytics events [here](cs-analytics.md) + +### Authorization events + +- **RefreshToken** + - Event notifying backend that authorization token has to be refreshed. diff --git a/gradle.properties b/gradle.properties index f3594ca5..7354f2a3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,7 +23,7 @@ android.useAndroidX=true android.enableJetifier=false android.nonTransitiveRClass=true # === Dependencies with plugins === -androidGradlePluginVersion=8.5.0 +androidGradlePluginVersion=8.5.1 # === Publishing === SONATYPE_HOST=DEFAULT RELEASE_SIGNING_ENABLED=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 772ecc5b..4ec8ccf0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,12 @@ [versions] jetbrains-annotations = "24.1.0" -kotlin = "1.9.24" -ksp = "1.9.24-1.0.20" -detekt = "1.23.4" -dokka = "1.9.10" +kotlin = "2.0.10" +kotlinx-serialization-json = "1.7.2" +ksp = "2.0.10-1.0.24" + +detekt = "1.23.6" +dokka = "1.9.20" androidx-ktx = "1.13.1" gson = "2.11.0" @@ -12,31 +14,31 @@ okhttp = "4.12.0" retrofit = "2.11.0" security-crypto = "1.0.0" -androidx-appcompat = "1.6.1" -androidx-datastore = "1.0.0" -androidx-compose-bom = "2024.05.00" -androidx-compose-activity = "1.7.2" +androidx-appcompat = "1.7.0" +androidx-datastore = "1.1.1" +androidx-compose-bom = "2024.06.00" +androidx-compose-activity = "1.9.2" androidx-constraintlayout = "1.0.1" -androidx-lifecycle = "2.7.0" -androidx-media3 = "1.3.1" -androidx-navigation = "2.7.7" +androidx-lifecycle = "2.8.6" +androidx-media3 = "1.4.1" +androidx-navigation = "2.8.1" androidx-safe-args = "2.7.7" -androidx-test-runner = "1.5.2" -androidx-test-espresso = "3.5.1" -compose-markdown = "0.4.1" -coil = "2.6.0" +androidx-test-runner = "1.6.2" +androidx-test-espresso = "3.6.1" +compose-markdown = "0.5.2" +coil = "2.7.0" emoji = "1.4.0" findbugs = "3.0.2" -firebase = "33.0.0" +firebase = "33.1.2" koin = "3.5.6" koin-annotations = "1.3.1" -kotlin-coroutines = "1.7.3" -zoomable = "1.6.1" +kotlin-coroutines = "1.8.1" +zoomable = "1.6.2" junit = "4.13.2" -junit-jupiter = "5.9.2" -mockk = "1.13.8" -kotest = "5.8.1" +junit-jupiter = "5.11.0" +mockk = "1.13.12" +kotest = "5.9.1" strucut = "[1,2[" [libraries] @@ -48,12 +50,14 @@ kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", # kotlin jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } +kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib" } # test junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-reflection = { group = "org.jetbrains.kotlin", name = "kotlin-reflect" } kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } strucut = { group = "io.github.diareuse", name = "strucut", version.ref = "strucut" } @@ -73,7 +77,6 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datastore" } androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" } -androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "androidx-navigation" } androidx-navigation-runtime = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "androidx-navigation" } androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "androidx-navigation" } kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlin-coroutines" } @@ -82,10 +85,10 @@ kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutine androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } androidx-compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-compose-activity" } androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } -androidx-material = { group = "androidx.compose.material", name = "material", version = "1.7.0-beta01" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } -androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version = "1.7.0-beta07" } androidx-compose-ui-graphic = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } @@ -103,8 +106,9 @@ androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "andr security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } -retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } # chat-ui androidx-lifecycle = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx-lifecycle" } @@ -122,7 +126,7 @@ zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zooma androidx-emoji2 = { module = "androidx.emoji2:emoji2", version.ref = "emoji" } androidx-emoji-bundled = { module = "androidx.emoji2:emoji2-bundled", version.ref = "emoji" } androidx-lifecycle-common = { group = "androidx.lifecycle", name = "lifecycle-common", version.ref = "androidx-lifecycle" } -androidx-startup = { group = "androidx.startup", name = "startup-runtime", version = "1.1.1" } +androidx-startup = { group = "androidx.startup", name = "startup-runtime", version = "1.2.0" } colorpicker = { group = "com.godaddy.android.colorpicker", name = "compose-color-picker-android", version = "0.7.0" } firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx" } @@ -133,14 +137,16 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } androidx-safeargs = { id = "androidx.navigation.safeargs", version.ref = "androidx-safe-args" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } firebase-appdistribution = { id = "com.google.firebase.appdistribution", version = "5.0.0" } -firebase-crashlytics = { id = "com.google.firebase.crashlytics", version = "3.0.1" } -google-services = { id = "com.google.gms.google-services", version = "4.4.1" } -maven-publish = { id = "com.vanniktech.maven.publish", version = "0.28.0" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version = "3.0.2" } +google-services = { id = "com.google.gms.google-services", version = "4.4.2" } +maven-publish = { id = "com.vanniktech.maven.publish", version = "0.29.0" } metalava = { id = "me.tylerbwong.gradle.metalava", version = "0.3.5" } rootcoverage = { id = "nl.neotech.plugin.rootcoverage", version = "1.8.0" } semantic-version = { id = "com.dipien.semantic-version", version = "2.0.0" } diff --git a/gradlew b/gradlew index 1aa94a42..f5feea6d 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 25da30db..9d21a218 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/logger-android/README.MD b/logger-android/README.MD index 2546bc75..acd048cc 100644 --- a/logger-android/README.MD +++ b/logger-android/README.MD @@ -1,3 +1,5 @@ # About: The logger-android module provides default android specific implementation of logger. + +You can find information about usage in the [Case Study - Logging](../docs/cs-logging.md). diff --git a/logger-android/build.gradle.kts b/logger-android/build.gradle similarity index 85% rename from logger-android/build.gradle.kts rename to logger-android/build.gradle index 5beafaef..07097ca0 100644 --- a/logger-android/build.gradle.kts +++ b/logger-android/build.gradle @@ -27,18 +27,16 @@ plugins { android { namespace = "com.nice.cxonechat.log.android" + defaultConfig { + consumerProguardFiles "consumer-rules.pro" + versionName version + } } -dependencies { - api(project(":logger")) +mavenPublishing { + configure(new AndroidSingleVariantLibrary("release", true, true)) } -mavenPublishing { - configure( - AndroidSingleVariantLibrary( - variant = "release", - sourcesJar = true, - publishJavadocJar = true, - ) - ) +dependencies { + api(project(":logger")) } diff --git a/logger/README.MD b/logger/README.MD index 48dd5520..73af9693 100644 --- a/logger/README.MD +++ b/logger/README.MD @@ -1,3 +1,5 @@ # About: The logger module is minimalistic logging framework used by the chat-sdk-core without any platform specific code. + +You can find information about usage in the [Case Study - Logging](../docs/cs-logging.md). diff --git a/logger/build.gradle.kts b/logger/build.gradle.kts index 6317fd75..264820ec 100644 --- a/logger/build.gradle.kts +++ b/logger/build.gradle.kts @@ -13,7 +13,7 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -import com.vanniktech.maven.publish.JavaLibrary +import com.vanniktech.maven.publish.KotlinJvm import com.vanniktech.maven.publish.JavadocJar plugins { @@ -28,10 +28,10 @@ plugins { mavenPublishing { configure( - JavaLibrary( - javadocJar = JavadocJar.Javadoc(), - // whether to publish a sources jar - sourcesJar = true, - ) + KotlinJvm( + javadocJar = JavadocJar.Dokka("dokkaHtml"), + // whether to publish a sources jar + sourcesJar = true, + ) ) } diff --git a/readme.md b/readme.md index 13b21466..7a0edfcd 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,7 @@ which are described in chapters below. ## CXOne Chat SDK -This is the main published module, it is released as an android multi-flavor library with maven artifact coordinates +This is the only published module, it is released as android multi-flavor library with maven artifact coordinates `com.nice.cxone:chat-core`. ### Adding the dependency @@ -50,23 +50,26 @@ You can use any uprivilidged valid token, since the package are public. Then you can the dependency simply by adding: ```groovy - implementation "com.nice.cxone:chat-sdk-core:2.1.1" + implementation "com.nice.cxone:chat-sdk-core:$currentVersion" ``` ### Additional information -Visit [documentation][docs] for more information about SDK API. +Visit [NICE documentation][NICE-docs] for more information about CXone Chat and pre-requisites for the SDK. + +Current [API][API]. You can also find a simplified example of possible SDK usage in [case studies](docs/case-studies.md) documentation. We offer a brief how-to guide for integration [here][implementation]. -[docs]: https://help.nice-incontact.com/content/acd/digital/mobilesdk/android/getstartedandroid.htm -[API]: https://help.nice-incontact.com/mobilesdk/Android1.3/dist/index.html +[NICE-docs]: https://help.nice-incontact.com/content/acd/digital/mobilesdk/cxonemobilesdk.htm +[API]: https://nice-devone.github.io/nice-cxone-mobile-sdk-android/ +[API-1.3]: https://help.nice-incontact.com/mobilesdk/Android1.3/dist/index.html [implementation]: docs/implementation.md -## CXOne Chat UI +## CXone Chat UI This is a sample implementation of the UI for CXOne Chat SDK, which allows easier integration of SDK into the intended target application. diff --git a/store/build.gradle b/store/build.gradle index d7077155..296c7779 100644 --- a/store/build.gradle +++ b/store/build.gradle @@ -24,6 +24,7 @@ plugins { alias (libs.plugins.google.services) alias (libs.plugins.firebase.appdistribution) alias (libs.plugins.firebase.crashlytics) + id "org.jetbrains.kotlin.plugin.serialization" } def storeVersion = branchVersion(version, project) @@ -59,7 +60,7 @@ android { versionNameSuffix '-debug' } release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' signingConfig signingConfigs.release } @@ -95,7 +96,8 @@ android.applicationVariants.all { variant -> dependencies { implementation libs.retrofit - implementation libs.retrofit.gson + implementation libs.kotlinx.serialization.json + implementation libs.retrofit.kotlinx.serialization // Initializer implementation libs.androidx.lifecycle.common @@ -114,6 +116,7 @@ dependencies { // AsyncImage implementation libs.coil.compose + implementation libs.okhttp.logging // CXOne Chat SDK implementation project(":chat-sdk-core") diff --git a/store/src/debug/AndroidManifest.xml b/store/src/debug/AndroidManifest.xml deleted file mode 100644 index 0558f207..00000000 --- a/store/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - diff --git a/store/src/main/AndroidManifest.xml b/store/src/main/AndroidManifest.xml index 065b5380..08ccfbad 100644 --- a/store/src/main/AndroidManifest.xml +++ b/store/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ android:theme="@style/StoreFrontTheme" android:useEmbeddedDex="true" android:allowAudioPlaybackCapture="false" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="31"> , val total: Int, val skip: Int, diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfiguration.kt b/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfiguration.kt index a85ab01c..3af193dd 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfiguration.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfiguration.kt @@ -15,8 +15,9 @@ package com.nice.cxonechat.sample.data.models -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.SocketFactoryConfiguration +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * A savable configuration for the SDK. @@ -27,20 +28,20 @@ import com.nice.cxonechat.SocketFactoryConfiguration * @param brandId Brand ID for configuration. * @param channelId Channel ID to configure. */ +@Serializable data class SdkConfiguration( - @SerializedName("name") + @SerialName("name") val name: String, - @SerializedName("environment") + @SerialName("environment") val environment: SdkEnvironment, - @SerializedName("brandId") + @SerialName("brandId") val brandId: Long, - @SerializedName("channelId") + @SerialName("channelId") val channelId: String, ) { /** * Convert a saved SdkConfiguration to a SocketFactoryConfiguration for building a chat. * - * @param context Android context, used to fetch the version name. * @return Appropriately constructed SdkConfiguration. */ val asSocketFactoryConfiguration: SocketFactoryConfiguration diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfigurationList.kt b/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfigurationList.kt index af83ee36..e22dfcaa 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfigurationList.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkConfigurationList.kt @@ -15,7 +15,8 @@ package com.nice.cxonechat.sample.data.models -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import javax.annotation.concurrent.Immutable /** @@ -24,7 +25,8 @@ import javax.annotation.concurrent.Immutable * @param configurations The list of defined configurations. */ @Immutable +@Serializable data class SdkConfigurationList( - @SerializedName("configurations") + @SerialName("configurations") val configurations: SdkConfigurations, ) diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkEnvironment.kt b/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkEnvironment.kt index ae9c5573..5129a38e 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkEnvironment.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/models/SdkEnvironment.kt @@ -15,23 +15,25 @@ package com.nice.cxonechat.sample.data.models -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.state.Environment +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** * Serializable version [SdkEnvironment]. */ +@Serializable data class SdkEnvironment( - @SerializedName("name") + @SerialName("name") override val name: String, - @SerializedName("location") + @SerialName("location") override val location: String, - @SerializedName("baseUrl") + @SerialName("baseUrl") override val baseUrl: String, - @SerializedName("socketUrl") + @SerialName("socketUrl") override val socketUrl: String, - @SerializedName("originHeader") + @SerialName("originHeader") override val originHeader: String, - @SerializedName("chatUrl") + @SerialName("chatUrl") override val chatUrl: String, ) : Environment diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/models/UISettingsModel.kt b/store/src/main/java/com/nice/cxonechat/sample/data/models/UISettingsModel.kt index 3caeb810..e6cbbcd1 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/models/UISettingsModel.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/models/UISettingsModel.kt @@ -17,11 +17,17 @@ package com.nice.cxonechat.sample.data.models import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color -import com.google.gson.annotations.SerializedName import com.nice.cxonechat.sample.ui.theme.Colors.Dark import com.nice.cxonechat.sample.ui.theme.Colors.DefaultColors import com.nice.cxonechat.sample.ui.theme.Colors.Light import com.nice.cxonechat.sample.ui.theme.Images +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder /** * UI Settings as saved to file. @@ -31,12 +37,13 @@ import com.nice.cxonechat.sample.ui.theme.Images * @param storedLogo Logo image which should be used for chat branding. */ @Immutable +@Serializable data class UISettingsModel( - @SerializedName("lightModeColors") + @SerialName("lightModeColors") val lightModeColors: Colors = Colors(Light), - @SerializedName("darkModeColors") + @SerialName("darkModeColors") val darkModeColors: Colors = Colors(Dark), - @SerializedName("logo") + @SerialName("logo") private val storedLogo: String? = null, ) { @@ -58,26 +65,37 @@ data class UISettingsModel( * @param customerBackground Background color for customer cells in chat. * @param customerText Text color for customer cells in chat. */ + @Serializable data class Colors( - @SerializedName("primary") + @SerialName("primary") + @Serializable(with = ColorSerializer::class) val primary: Color, - @SerializedName("onPrimary") + @SerialName("onPrimary") + @Serializable(with = ColorSerializer::class) val onPrimary: Color, - @SerializedName("accent") + @SerialName("accent") + @Serializable(with = ColorSerializer::class) val accent: Color, - @SerializedName("onAccent") + @SerialName("onAccent") + @Serializable(with = ColorSerializer::class) val onAccent: Color, - @SerializedName("background") + @SerialName("background") + @Serializable(with = ColorSerializer::class) val background: Color, - @SerializedName("onBackground") + @SerialName("onBackground") + @Serializable(with = ColorSerializer::class) val onBackground: Color, - @SerializedName("agentBubble") + @SerialName("agentBubble") + @Serializable(with = ColorSerializer::class) val agentBackground: Color, - @SerializedName("agentText") + @SerialName("agentText") + @Serializable(with = ColorSerializer::class) val agentText: Color, - @SerializedName("customerBubble") + @SerialName("customerBubble") + @Serializable(with = ColorSerializer::class) val customerBackground: Color, - @SerializedName("customerText") + @SerialName("customerText") + @Serializable(with = ColorSerializer::class) val customerText: Color, ) { constructor(defaults: DefaultColors) : this( @@ -94,3 +112,13 @@ data class UISettingsModel( ) } } + +private class ColorSerializer : KSerializer { + override val descriptor: SerialDescriptor = ULong.serializer().descriptor + + override fun deserialize(decoder: Decoder): Color = ULong.serializer().deserialize(decoder).let(::Color) + + override fun serialize(encoder: Encoder, value: Color) { + ULong.serializer().serialize(encoder, value.value) + } +} diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/ChatSettingsRepository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/ChatSettingsRepository.kt index c13d2020..b18c2837 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/ChatSettingsRepository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/ChatSettingsRepository.kt @@ -16,6 +16,7 @@ package com.nice.cxonechat.sample.data.repository import android.content.Context +import androidx.annotation.Keep import com.nice.cxonechat.sample.data.models.ChatSettings import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,6 +44,7 @@ class ChatSettingsRepository( * * @return newly loaded settings. */ + @Keep // Remove once the DE-117407 is resolved fun load() = super.load(context).also { mutableSettings.value = it } diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/Repository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/Repository.kt index 401d62b7..ec689ee0 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/Repository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/Repository.kt @@ -16,8 +16,9 @@ package com.nice.cxonechat.sample.data.repository import android.content.Context -import com.google.gson.Gson -import com.google.gson.JsonIOException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -26,7 +27,7 @@ import kotlin.reflect.KClass /** * Abstract concept of a named place to store "complex" data. * - * Data will be converted to JSON (using Gson) and then saved via the described mechanism. + * Data will be converted to JSON and then saved via the described mechanism. * * @param Type type of data to be stored. * @param type class of data to be stored. @@ -39,10 +40,10 @@ abstract class Repository( * * @param context Android [Context] to be used for resource resolution and/or file access. * @param item item to be saved. - * @throws [JsonIOException] if Gson encounters an error. + * @throws [SerializationException] if Json encounters an error. * @throws [Exception] rethrows any exception thrown by [doStore] */ - @Throws(JsonIOException::class) + @Throws(SerializationException::class) open fun save(context: Context, item: Type?) { if (item != null) { doStore(toJson(item), context) @@ -56,10 +57,10 @@ abstract class Repository( * * @param context Android [Context] to be used for resource resolution and/or file access. * @return item loaded. - * @throws [JsonIOException] if Gson encounters an error. + * @throws [SerializationException] if Json encounters an error. * @throws any exception thrown by [doLoad] will be rethrown. */ - @Throws(JsonIOException::class) + @Throws(SerializationException::class) open fun load(context: Context) = doLoad(context)?.let(::fromJson) /** @@ -128,16 +129,21 @@ abstract class Repository( * * @param item Data item to convert to Json. * @return item converted to Json as a [String]. - * @throws [JsonIOException] if any error is encountered during the conversion. + * @throws [SerializationException] if any error is encountered during the conversion. */ - private fun toJson(item: Type) = Gson().toJson(item) + private fun toJson(item: Type) = json.encodeToString(serializer = json.serializersModule.serializer(type.java), value = item) /** * Parse an item of type Type from a Json-encoded [String]. * * @param string [String] to parse as Json. * @return object of type Type parsed from [string]. - * @throws [JsonIOException] if any error is encountered during the conversion. + * @throws [SerializationException] if any error is encountered during the conversion. */ - private fun fromJson(string: String) = Gson().fromJson(string, type.java) + private fun fromJson(string: String): Type = + json.decodeFromString(deserializer = json.serializersModule.serializer(type.java), string = string) as Type +} + +private val json = Json { + ignoreUnknownKeys = true } diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/UISettingsRepository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/UISettingsRepository.kt index 2ea3e7d0..017c7cb4 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/UISettingsRepository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/UISettingsRepository.kt @@ -16,6 +16,7 @@ package com.nice.cxonechat.sample.data.repository import android.content.Context +import androidx.annotation.Keep import com.nice.cxonechat.sample.data.models.UISettingsModel import com.nice.cxonechat.sample.data.models.UISettingsModel.Colors import com.nice.cxonechat.ui.composable.theme.ChatThemeDetails @@ -76,6 +77,7 @@ class UISettingsRepository( * Load any available saved UI Settings. Default settings will be applied if no saved * settings are located. */ + @Keep // Remove once the DE-117407 is resolved fun load() = super.load(context).also { UISettingsState.value = (it ?: UISettingsModel()).apply { applyToChatSdk() diff --git a/store/src/main/java/com/nice/cxonechat/sample/extensions/Context.kt b/store/src/main/java/com/nice/cxonechat/sample/extensions/Context.kt index 6cf26da6..c8be281d 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/extensions/Context.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/extensions/Context.kt @@ -20,30 +20,6 @@ import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.PackageInfoFlags import android.os.Build.VERSION import android.os.Build.VERSION_CODES -import androidx.annotation.DrawableRes -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.graphics.painter.Painter -import androidx.core.graphics.drawable.toBitmap - -/** - * Create a Compose [Painter] from a drawable resource. - * - * Note: Intended for usage *only* with mipmap resources (like launcher icons). - * For other resource types use [painterResource](androidx.compose.ui.res.painterResource). - * - * @param resId drawable resource id - * @return a [Painter] resource suitable for usage with [Icon](androidx.compose.material.Icon) - * and [Image](androidx.compose.foundation.Image). - */ -fun Context.mipmapPainter(@DrawableRes resId: Int): Painter? = AppCompatResources - .getDrawable(this, resId) - ?.toBitmap() - ?.asImageBitmap() - ?.let { - return BitmapPainter(it) - } /** * Fetch the `versionName` value from the manifest file. diff --git a/store/src/main/java/com/nice/cxonechat/sample/network/DummyJsonService.kt b/store/src/main/java/com/nice/cxonechat/sample/network/DummyJsonService.kt index ccffded6..4f452fe9 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/network/DummyJsonService.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/network/DummyJsonService.kt @@ -18,9 +18,13 @@ package com.nice.cxonechat.sample.network import com.nice.cxonechat.sample.data.models.Product import com.nice.cxonechat.sample.data.models.ProductList import com.nice.cxonechat.utilities.TaggingSocketFactory +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.logging.HttpLoggingInterceptor.Level.BASIC import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.http.GET import retrofit2.http.Path @@ -46,18 +50,29 @@ interface DummyJsonService { @GET("/product/{productId}") suspend fun product(@Path("productId") productId: String): Product + @Suppress("UndocumentedPublicClass") companion object { private val client by lazy { OkHttpClient.Builder() .socketFactory(TaggingSocketFactory) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = BASIC + } + ) .build() } + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + /** singleton instance of DummyJsonService provided by retrofit. */ val dummyJsonService: DummyJsonService by lazy { Retrofit.Builder() .baseUrl("https://dummyjson.com") - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(json.asConverterFactory("application/json; charset=UTF-8".toMediaType())) .client(client) .build() .create(DummyJsonService::class.java) diff --git a/store/src/main/java/com/nice/cxonechat/sample/previewproviders/ProductsParameterProvider.kt b/store/src/main/java/com/nice/cxonechat/sample/previewproviders/ProductsParameterProvider.kt index bb4a0ec8..84b04a62 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/previewproviders/ProductsParameterProvider.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/previewproviders/ProductsParameterProvider.kt @@ -16,9 +16,9 @@ package com.nice.cxonechat.sample.previewproviders import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.gson.Gson import com.nice.cxonechat.sample.data.models.Product import com.nice.cxonechat.sample.data.models.ProductList +import kotlinx.serialization.json.Json /** * PreviewParameterProvider providing a list of products for the product list page. @@ -29,7 +29,7 @@ class ProductsParameterProvider: PreviewParameterProvider> { private companion object { val items by lazy { - Gson().fromJson(JSON, ProductList::class.java).items + Json.Default.decodeFromString(JSON).items } private const val JSON = """ diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/BusySpinner.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/BusySpinner.kt index 0c6f8a89..f46d80f3 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/BusySpinner.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/BusySpinner.kt @@ -19,9 +19,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Card -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Text +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,7 +42,11 @@ fun BusySpinner(message: String, onCancel: (() -> Unit)? = null) { Dialog( onDismissRequest = { }, ) { - Card(backgroundColor = AppTheme.colors.background.copy(alpha = 0.75f)) { + Card( + colors = CardDefaults.cardColors( + containerColor = AppTheme.colorScheme.background.copy(alpha = 0.75f) + ) + ) { Column( modifier = Modifier.padding(AppTheme.space.defaultPadding), horizontalAlignment = Alignment.CenterHorizontally, @@ -49,7 +54,7 @@ fun BusySpinner(message: String, onCancel: (() -> Unit)? = null) { ) { CircularProgressIndicator( modifier = Modifier.size(32.dp), - color = AppTheme.colors.primary, + color = AppTheme.colorScheme.primary, ) Text(message) onCancel?.let { @@ -63,7 +68,7 @@ fun BusySpinner(message: String, onCancel: (() -> Unit)? = null) { } } -@Preview(showBackground = true, showSystemUi = true) +@Preview @Composable private fun BusySpinnerPreview() { AppTheme { diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/CartScreen.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/CartScreen.kt index a602d402..f32cb32d 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/CartScreen.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/CartScreen.kt @@ -26,13 +26,13 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -168,7 +168,7 @@ object CartScreen : Screen { ) { item { Header(modifier = Modifier.padding(bottom = 4.dp)) - Divider(thickness = 2.dp, color = AppTheme.colors.onBackground) + HorizontalDivider(thickness = 2.dp, color = AppTheme.colorScheme.onBackground) } itemsIndexed(items = cart.items, key = { _, item -> item.productId }) { index, item -> @@ -180,12 +180,12 @@ object CartScreen : Screen { updateItem = updateItem ) if (index != cart.items.lastIndex) { - Divider() + HorizontalDivider() } } item { - Divider(thickness = 2.dp, color = AppTheme.colors.onBackground) + HorizontalDivider(thickness = 2.dp, color = AppTheme.colorScheme.onBackground) Footer(cart.total, modifier = Modifier.padding(top = 4.dp)) } } @@ -215,7 +215,7 @@ object CartScreen : Screen { @Composable private fun Footer(total: Double, modifier: Modifier = Modifier) { Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Text(total.asCurrency, style = AppTheme.typography.body1.bold) + Text(total.asCurrency, style = AppTheme.typography.bodyLarge.bold) } } @@ -245,7 +245,7 @@ object CartScreen : Screen { updateItem(item.copy(quantity = quantity.toInt())) } }, - textStyle = AppTheme.typography.body1.copy(textAlign = TextAlign.End), + textStyle = AppTheme.typography.bodyLarge.copy(textAlign = TextAlign.End), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal, imeAction = if(more) ImeAction.Next else ImeAction.Done diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/ConfirmationScreen.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/ConfirmationScreen.kt index 9e9b6e2e..63bb909c 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/ConfirmationScreen.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/ConfirmationScreen.kt @@ -19,7 +19,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/Drawer.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/Drawer.kt index defb8627..979f0784 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/Drawer.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/Drawer.kt @@ -24,23 +24,22 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.Icons.AutoMirrored.Filled +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import coil.compose.AsyncImage import com.nice.cxonechat.sample.R import com.nice.cxonechat.sample.R.string import com.nice.cxonechat.sample.extensions.manifestVersionName -import com.nice.cxonechat.sample.extensions.mipmapPainter import com.nice.cxonechat.sample.ui.theme.AppTheme /** @@ -64,15 +63,15 @@ fun Drawer( val context = LocalContext.current Header() - Divider() + HorizontalDivider() Item(context.manifestVersionName ?: stringResource(string.default_version_name)) - Divider() + HorizontalDivider() Item(stringResource(string.sdk_settings), onClick = onSdkSettings) - Divider() + HorizontalDivider() Item(stringResource(id = string.ui_settings), onClick = onUiSettings) - Divider() + HorizontalDivider() Spacer(modifier = Modifier.weight(1f)) - Divider() + HorizontalDivider() Item(stringResource(string.logout), onClick = onLogout) } } @@ -84,14 +83,12 @@ private fun Header() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(AppTheme.space.large) ) { - val context = LocalContext.current - val launcherIcon = remember { context.mipmapPainter(R.mipmap.ic_launcher) } - - launcherIcon?.let { - Image(painter = it, contentDescription = null) - } + AsyncImage( + model = R.mipmap.ic_launcher, + contentDescription = null, + ) - Text(stringResource(string.app_name), style = AppTheme.typography.h4) + Text(stringResource(string.app_name), style = AppTheme.typography.headlineMedium) } } @@ -117,7 +114,7 @@ private fun Item( if(clickable) { Spacer(Modifier.weight(1f)) - Icon(Icons.Default.KeyboardArrowRight, null) + Icon(Filled.KeyboardArrowRight, null) } } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/PaymentScreen.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/PaymentScreen.kt index a55f4532..69ae32ef 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/PaymentScreen.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/PaymentScreen.kt @@ -19,8 +19,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/ProductListScreen.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/ProductListScreen.kt index 7839c2b1..5d503bb4 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/ProductListScreen.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/ProductListScreen.kt @@ -17,23 +17,26 @@ package com.nice.cxonechat.sample.ui import android.annotation.SuppressLint import android.app.Activity -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.absolutePadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.material.AlertDialog -import androidx.compose.material.Card -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -45,8 +48,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -81,7 +87,7 @@ object ProductListScreen : Screen { private val routeFormat = routeTo("{$categoryKey}") /** route to get back to this screen. */ - val defaultRoute = routeFormat + val defaultRoute = routeTo() private fun routeTo(category: String = defaultCategory) = "products/$category" @@ -114,8 +120,12 @@ object ProductListScreen : Screen { viewModel = viewModel, category = category, attempt = attempt, - onError = { error = it }, - onSuccess = { products = it } + onError = { + error = it + }, + onSuccess = { + products = it + } ) Screen( @@ -217,7 +227,6 @@ object ProductListScreen : Screen { } } - @OptIn(ExperimentalFoundationApi::class) @Composable private fun ProductListView( products: List, @@ -242,22 +251,30 @@ object ProductListScreen : Screen { @Composable private fun ProductCard(product: Product, modifier: Modifier = Modifier) { + val screenWidth = LocalConfiguration.current.screenWidthDp * 3 / 4 / 2 + var size by remember { mutableIntStateOf(screenWidth) } + Card( - modifier = modifier.fillMaxWidth(), - elevation = 3.dp, + modifier = modifier.fillMaxWidth().onSizeChanged { size = it.width * 3 / 4 }, + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), ) { Column( - modifier = Modifier - .fillMaxWidth(), + modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { AsyncImage( model = product.thumbnail, contentDescription = null, - modifier = Modifier.absolutePadding(0.dp), - contentScale = ContentScale.FillWidth + modifier = Modifier + .then( + with(LocalDensity.current) { + Modifier.height(size.toDp()) + } + ), + placeholder = rememberVectorPainter(Icons.Default.Photo), + error = rememberVectorPainter(Icons.Default.Error), ) - Text(product.title, modifier = Modifier.padding(horizontal = space.medium)) + Text(product.title, modifier = Modifier.padding(horizontal = space.medium), maxLines = 1) Text( product.price.asCurrency, modifier = Modifier.padding(horizontal = space.medium), diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/ProductScreen.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/ProductScreen.kt index d592838a..4d4027e4 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/ProductScreen.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/ProductScreen.kt @@ -26,10 +26,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width -import androidx.compose.material.AlertDialog -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Text +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -208,7 +208,7 @@ object ProductScreen : Screen { @Composable private fun Headline(text: String) { - Text(text, style = AppTheme.typography.h5.bold) + Text(text, style = AppTheme.typography.headlineSmall.bold) } @Composable @@ -216,8 +216,8 @@ object ProductScreen : Screen { Text( text, modifier = Modifier.fillMaxWidth(1f), - style = AppTheme.typography.body1, - color = AppTheme.colors.onBackground + style = AppTheme.typography.bodyLarge, + color = AppTheme.colorScheme.onBackground ) } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/SdkConfigurationDialog.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/SdkConfigurationDialog.kt index aa592b51..e2ad709c 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/SdkConfigurationDialog.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/SdkConfigurationDialog.kt @@ -21,7 +21,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -178,7 +178,7 @@ private fun ConfigurationSelector( Column( modifier - .border(1.dp, AppTheme.colors.onBackground.copy(alpha = 0.50f), RoundedCornerShape(4.dp)) + .border(1.dp, AppTheme.colorScheme.onBackground.copy(alpha = 0.50f), RoundedCornerShape(4.dp)) ) { DropdownField( modifier = Modifier.padding(space.defaultPadding), @@ -193,7 +193,7 @@ private fun ConfigurationSelector( @Composable private fun CustomEnvironmentDetails(state: SdkConfigurationState) { if(state.isCustomConfiguration) { - Divider(modifier = Modifier.padding(vertical = space.medium)) + HorizontalDivider(modifier = Modifier.padding(vertical = space.medium)) EnvironmentSelector(state) BrandIdField(state) ChannelIdField(state) @@ -208,7 +208,7 @@ private fun EnvironmentSelector( Column( modifier .padding(top = space.medium) - .border(1.dp, AppTheme.colors.onBackground.copy(alpha = 0.50f), RoundedCornerShape(4.dp)) + .border(1.dp, AppTheme.colorScheme.onBackground.copy(alpha = 0.50f), RoundedCornerShape(4.dp)) ) { DropdownField( modifier = modifier.padding(space.defaultPadding), diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/components/DropdownField.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/components/DropdownField.kt index 8bb4cfd3..dd8dda43 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/components/DropdownField.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/components/DropdownField.kt @@ -23,17 +23,16 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.Divider -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +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 @@ -58,7 +57,7 @@ data class SimpleDropdownItem( * @param label Label for field. * @param placeholder Text to be displayed if no value is chosen. * @param value Current value of the field. - constructor(entry: Map.Entry) : this(entry.key, entry.value) +constructor(entry: Map.Entry) : this(entry.key, entry.value) * @param options List of options the field can take. * @param isError is the field in error. * @param onDismiss the dropdown menu was dismissed. @@ -102,10 +101,11 @@ fun DropdownField( modifier = Modifier.align(Alignment.BottomStart) ) { if (placeholder.isNotBlank()) { - DropdownMenuItem(onClick = { }) { - Text(placeholder) - } - Divider() + DropdownMenuItem( + onClick = { }, + text = { Text(placeholder) }, + ) + HorizontalDivider() } options.forEach { item -> DropdownMenuItem( @@ -113,14 +113,15 @@ fun DropdownField( expanded = false onSelect(item.value) }, - ) { - when(value) { - item.value -> SelectedIcon() - "" -> Unit - else -> Spacer(Modifier.width(16.dp)) - } - Text(item.label) - } + leadingIcon = { + when (value) { + item.value -> SelectedIcon() + "" -> Unit + else -> Spacer(Modifier.width(16.dp)) + } + }, + text = { Text(item.label) } + ) } } } @@ -185,9 +186,9 @@ private fun ErrorLabel( Text( text = label, color = if (isError) { - MaterialTheme.colors.error + MaterialTheme.colorScheme.error } else { - LocalContentColor.current.copy(LocalContentAlpha.current) + LocalContentColor.current.copy(alpha = 0.38f) } ) } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/components/ImageCarousel.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/components/ImageCarousel.kt index f216eaf3..484f042c 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/components/ImageCarousel.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/components/ImageCarousel.kt @@ -28,7 +28,10 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,6 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -102,6 +106,8 @@ fun ImageCarousel( .carouselTransition(index, pagerState), alignment = Alignment.Center, contentScale = ContentScale.Fit, + placeholder = rememberVectorPainter(Icons.Default.Photo), + error = rememberVectorPainter(Icons.Default.Error), ) } @@ -132,8 +138,8 @@ private fun DotIndicators( pageCount: Int, pagerState: PagerState, modifier: Modifier = Modifier, - selectedColor: Color = MaterialTheme.colors.primary, - unselectedColor: Color = MaterialTheme.colors.secondary, + selectedColor: Color = MaterialTheme.colorScheme.primary, + unselectedColor: Color = MaterialTheme.colorScheme.secondary, dotSize: Dp = 6.dp, dotSpacing: Dp = 3.dp, ) { @@ -155,8 +161,8 @@ private fun DotIndicators( @Composable private fun DotIndicator( selected: Boolean, - selectedColor: Color = MaterialTheme.colors.primary, - unselectedColor: Color = MaterialTheme.colors.secondary, + selectedColor: Color = MaterialTheme.colorScheme.primary, + unselectedColor: Color = MaterialTheme.colorScheme.secondary, dotSize: Dp = 6.dp, ) { val color = if (selected) { diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/components/RatingBar.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/components/RatingBar.kt index 5589a0e6..3f53d815 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/components/RatingBar.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/components/RatingBar.kt @@ -17,9 +17,9 @@ package com.nice.cxonechat.sample.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -50,7 +50,7 @@ fun RatingBar( rating: Double, modifier: Modifier = Modifier, stars: Int = 5, - starsColor: Color = colors.primary, + starsColor: Color = colorScheme.primary, filledStar: Painter = painterResource(id = drawable.star), halfStar: Painter = painterResource(id = drawable.star_half), unfilledStar: Painter = painterResource(id = drawable.star_outline), diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Alert.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Alert.kt index d9320762..d2510266 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Alert.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Alert.kt @@ -16,7 +16,7 @@ package com.nice.cxonechat.sample.ui.theme import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -41,7 +41,7 @@ internal fun AppTheme.Alert( OutlinedButton(text = dismissLabel, onClick = onDismiss) } ) { - Text(message, modifier = Modifier.fillMaxWidth(), style = typography.body2) + Text(message, modifier = Modifier.fillMaxWidth(), style = typography.bodyMedium) } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/AppTheme.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/AppTheme.kt index 6d97cc82..c0a104a5 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/AppTheme.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/AppTheme.kt @@ -16,12 +16,12 @@ package com.nice.cxonechat.sample.ui.theme import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes -import androidx.compose.material.Typography -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable @@ -43,8 +43,8 @@ fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () } else { settings.lightModeColors } - val colors = if (darkTheme) { - darkColors( + val colorScheme = if (darkTheme) { + darkColorScheme( primary = theme.primary, onPrimary = theme.onPrimary, background = theme.background, @@ -55,7 +55,7 @@ fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () onSecondary = theme.onAccent, ) } else { - lightColors( + lightColorScheme( primary = theme.primary, onPrimary = theme.onPrimary, background = theme.background, @@ -77,7 +77,7 @@ fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () LocalSpace provides Space(), ) { MaterialTheme( - colors = colors, + colorScheme = colorScheme, typography = Typography, shapes = Shapes, content = content @@ -90,10 +90,10 @@ fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () */ object AppTheme { /** Retrieves the current [Colors] at the call site's position in the hierarchy. */ - val colors: Colors + val colorScheme: ColorScheme @Composable @ReadOnlyComposable - get() = MaterialTheme.colors + get() = MaterialTheme.colorScheme /** Retrieves the current [Typography] at the call site's position in the hierarchy. */ val typography: Typography diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Buttons.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Buttons.kt index 90c6eaa6..738e6c41 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Buttons.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Buttons.kt @@ -19,11 +19,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.ButtonColors -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Text +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.nice.cxonechat.sample.R.drawable import com.nice.cxonechat.sample.R.string -import androidx.compose.material.FloatingActionButton as MaterialFab +import androidx.compose.material3.FloatingActionButton as MaterialFab /** * The outlined button preferred through out the application with the AppTheme @@ -64,9 +64,9 @@ fun AppTheme.OutlinedButton( @Composable private fun AppTheme.buttonColors(isDefault: Boolean): ButtonColors { - val background = if (isDefault) colors.primary else Color.Transparent + val background = if (isDefault) colorScheme.primary else Color.Transparent return ButtonDefaults.buttonColors( - backgroundColor = background, + containerColor = background, contentColor = contentColorFor(background) ) } @@ -94,7 +94,7 @@ fun AppTheme.ContinueButton(onClick: () -> Unit) { */ @Composable fun AppTheme.ChatFab(onClick: () -> Unit) { - MaterialFab(onClick = onClick, backgroundColor = colors.primary) { + MaterialFab(onClick = onClick, containerColor = colorScheme.primary) { Icon(painterResource(drawable.ic_chat_24px), stringResource(string.open_chat)) } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Colors.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Colors.kt index deaf3ac8..5517f2bb 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Colors.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Colors.kt @@ -15,8 +15,8 @@ package com.nice.cxonechat.sample.ui.theme -import androidx.compose.material.LocalContentColor -import androidx.compose.material.contentColorFor +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color @@ -106,6 +106,6 @@ object Colors { @Composable @ReadOnlyComposable fun contentColorFor(backgroundColor: Color): Color = when (backgroundColor) { - Color.Transparent -> AppTheme.colors.primary - else -> AppTheme.colors.contentColorFor(backgroundColor) + Color.Transparent -> AppTheme.colorScheme.primary + else -> AppTheme.colorScheme.contentColorFor(backgroundColor) }.takeOrElse { LocalContentColor.current } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Dialog.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Dialog.kt index 6a52598f..f4ded73a 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Dialog.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Dialog.kt @@ -17,8 +17,8 @@ package com.nice.cxonechat.sample.ui.theme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material.AlertDialog -import androidx.compose.material.Text +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -49,7 +49,7 @@ internal fun AppTheme.Dialog( dismissButton = dismissButton, text = { Column { - title?.let { Text(title, modifier = Modifier.padding(bottom = space.medium), style = typography.h6) } + title?.let { Text(title, modifier = Modifier.padding(bottom = space.medium), style = typography.titleLarge) } content() } } @@ -82,7 +82,7 @@ internal fun AppTheme.Dialog( confirmButton = confirmButton, dismissButton = dismissButton, ) { - Text(text, style = typography.body2) + Text(text, style = typography.bodyMedium) } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ErrorLabel.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ErrorLabel.kt index 0b095f36..1d4182fc 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ErrorLabel.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ErrorLabel.kt @@ -15,10 +15,10 @@ package com.nice.cxonechat.sample.ui.theme -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -44,11 +44,10 @@ fun AppTheme.ErrorLabel(label: String?, error: String?) { error != null -> Text( label?.let { stringResource(string.error_validation_label, it, error) } ?: error, - Modifier.background(colors.background), - color = colors.error + color = colorScheme.error ) label != null -> - Text(label, Modifier.background(colors.background)) + Text(label) } } @@ -56,13 +55,14 @@ fun AppTheme.ErrorLabel(label: String?, error: String?) { @Composable private fun ErrorLabelPreview() { AppTheme { - Column( - Modifier - .background(AppTheme.colors.onBackground) - .padding(8.dp) - ) { - AppTheme.ErrorLabel(label = "Label", error = "Error") - AppTheme.ErrorLabel(label = "Label", error = null) + Surface { + Column( + Modifier + .padding(8.dp) + ) { + AppTheme.ErrorLabel(label = "Label", error = "Error") + AppTheme.ErrorLabel(label = "Label", error = null) + } } } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/MultiToggleButton.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/MultiToggleButton.kt index 6af4c8ba..ae03f04e 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/MultiToggleButton.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/MultiToggleButton.kt @@ -27,9 +27,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.Divider -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Divider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -55,7 +55,7 @@ fun AppTheme.MultiToggleButton( modifier: Modifier = Modifier, onToggleChange: (String) -> Unit ) { - val selectedTint = colors.primary + val selectedTint = colorScheme.primary val unselectedTint = Color.Unspecified Surface( diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ScreenWithScaffold.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ScreenWithScaffold.kt index cafff9cb..990aece4 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ScreenWithScaffold.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/ScreenWithScaffold.kt @@ -19,13 +19,16 @@ import android.app.Activity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Scaffold -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -54,23 +57,23 @@ fun AppTheme.ScreenWithScaffold( drawerContent: @Composable ((() -> Unit) -> Unit)? = null, content: @Composable () -> Unit, ) { - val scaffoldState = rememberScaffoldState() + val drawerState = rememberDrawerState(DrawerValue.Closed) val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val openDrawer: () -> Unit = { coroutineScope.launch { - scaffoldState.drawerState.open() + drawerState.open() } } val closeDrawer: () -> Unit = { coroutineScope.launch { - scaffoldState.drawerState.close() + drawerState.close() } } - val navigationIcon: @Composable (() -> Unit)? = when { + val navigationIcon: @Composable (() -> Unit) = when { drawerContent != null -> { { IconButton(onClick = openDrawer) { @@ -78,15 +81,41 @@ fun AppTheme.ScreenWithScaffold( } } } - else -> null + else -> { {} } } val onOpenChat: (() -> Unit) = { (context as? Activity)?.run(ChatActivity::startChat) } + val scaffoldWithContent: @Composable () -> Unit = { + ScaffoldWithContent(title, navigationIcon, actions, onOpenChat, content) + } + + if (drawerContent == null) { + scaffoldWithContent() + } else { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet { + drawerContent(closeDrawer) + } + }, + content = scaffoldWithContent + ) + } +} + +@Composable +private fun AppTheme.ScaffoldWithContent( + title: String, + navigationIcon: @Composable () -> Unit, + actions: @Composable (RowScope.() -> Unit), + onOpenChat: () -> Unit, + content: @Composable () -> Unit, +) { Scaffold( - scaffoldState = scaffoldState, topBar = { TopBar( title, @@ -95,16 +124,11 @@ fun AppTheme.ScreenWithScaffold( ) }, floatingActionButton = { ChatFab(onClick = onOpenChat) }, - drawerContent = drawerContent?.let { - { - drawerContent.invoke { closeDrawer() } - } - }, ) { paddingValues -> Box( modifier = Modifier - .padding(paddingValues) .padding(space.defaultPadding) + .padding(paddingValues) ) { content() } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Shape.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Shape.kt index 143c4389..e09492d4 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Shape.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Shape.kt @@ -16,7 +16,7 @@ package com.nice.cxonechat.sample.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes +import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp /** Default shapes for Material components. */ diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TextField.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TextField.kt index 7180a2b7..9af6406f 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TextField.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TextField.kt @@ -20,7 +20,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.OutlinedTextField +import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TopBar.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TopBar.kt index 64e2c6c0..1d5a8e76 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TopBar.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/TopBar.kt @@ -18,13 +18,16 @@ package com.nice.cxonechat.sample.ui.theme import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.layout.RowScope -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.Icons.AutoMirrored.Filled +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview @@ -35,23 +38,29 @@ import androidx.compose.ui.tooling.preview.Preview * @param title Title to display. * @param navigationIcon Navigation Icon to display on left of bar. * @param actions Any actions to display on right of bar. - * @param backgroundColor Background color for bar, defaults to AppTheme.colors.primary. + * @param containerColor Background color for bar, defaults to AppTheme.colors.primary. * @param contentColor Content color for bar, defaults to AppTheme.colors.onPrimary. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppTheme.TopBar( title: String, - navigationIcon: @Composable (() -> Unit)? = null, + navigationIcon: @Composable (() -> Unit) = { }, actions: @Composable RowScope.() -> Unit = { }, - backgroundColor: Color = colors.primary, - contentColor: Color = colors.onPrimary, + containerColor: Color = colorScheme.primary, + contentColor: Color = colorScheme.onPrimary, ) { TopAppBar( title = { Text(title) }, navigationIcon = navigationIcon, actions = actions, - backgroundColor = backgroundColor, - contentColor = contentColor, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + scrolledContainerColor = containerColor, + navigationIconContentColor = contentColor, + titleContentColor = contentColor, + actionIconContentColor = contentColor, + ), ) } @@ -64,7 +73,7 @@ private fun TopBarPreview() { title = "Some Title", navigationIcon = { IconButton(onClick = { }) { - Icon(Icons.Default.ArrowBack, null) + Icon(Filled.ArrowBack, null) } }, actions = { diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Type.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Type.kt index 7e724253..f49913c6 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Type.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Type.kt @@ -15,7 +15,7 @@ package com.nice.cxonechat.sample.ui.theme -import androidx.compose.material.Typography +import androidx.compose.material3.Typography /** Material typography to apply. */ val Typography = Typography() diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/ColorField.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/ColorField.kt index 8ffbecc1..cad58f7c 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/ColorField.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/ColorField.kt @@ -27,9 +27,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.AlertDialog -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/UISettingsDialog.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/UISettingsDialog.kt index fab0768e..f50275b9 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/UISettingsDialog.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/uisettings/UISettingsDialog.kt @@ -27,13 +27,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.AlertDialog -import androidx.compose.material.Button -import androidx.compose.material.Card -import androidx.compose.material.Divider -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -130,9 +130,9 @@ private fun SettingsView( .height(Min) ) { ImagePicker(settings, pickImage, onChanged) - Divider() + HorizontalDivider() ColorsSection(settings, onChanged) - Divider() + HorizontalDivider() Row( horizontalArrangement = Arrangement.Center, modifier = Modifier diff --git a/store/src/main/java/com/nice/cxonechat/sample/utilities/RuleBasedPenalty.kt b/store/src/main/java/com/nice/cxonechat/sample/utilities/RuleBasedPenalty.kt index 64ef2974..2e374a54 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/utilities/RuleBasedPenalty.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/utilities/RuleBasedPenalty.kt @@ -35,9 +35,11 @@ import kotlin.reflect.KClass * sequence with only the first match being performed. */ @RequiresApi(VERSION_CODES.P) -class RuleBasedPenalty( - private vararg val rules: Rule, +class RuleBasedPenalty private constructor( + private val rules: List, ) { + constructor(vararg rules: Rule?) : this(rules.filterNotNull()) + /** * A test predicate to match violations. */ @@ -109,6 +111,7 @@ class RuleBasedPenalty( * Create a rule to allow violations matching an exception. * * @param predicate [Predicate] to match. + * @return A matching rule. */ fun allow(predicate: Predicate) = Rule( predicate = predicate, @@ -120,7 +123,7 @@ class RuleBasedPenalty( /** * Match any violation. * - * @return true + * @return A [Predicate] that always matches. */ fun any() = Predicate { true } diff --git a/store/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/store/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index b9d5f260..ea201380 100644 --- a/store/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/store/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -17,4 +17,4 @@ - + \ No newline at end of file diff --git a/store/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/store/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index b9d5f260..ea201380 100644 --- a/store/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/store/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -17,4 +17,4 @@ - + \ No newline at end of file diff --git a/store/src/main/res/raw/lets_encrypt.pem b/store/src/main/res/raw/lets_encrypt.pem new file mode 100644 index 00000000..b85c8037 --- /dev/null +++ b/store/src/main/res/raw/lets_encrypt.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/store/src/main/res/values/themes.xml b/store/src/main/res/values/themes.xml index 6fc5a304..803a915b 100644 --- a/store/src/main/res/values/themes.xml +++ b/store/src/main/res/values/themes.xml @@ -16,4 +16,4 @@