diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0a6eb3f1..98531032 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,5 @@ # Global owners -* @pavelreiter @nice-devone/DFO @hendrickson-tyler +* @pavelreiter @davidberry-nice @nice-devone/DFO @hendrickson-tyler # CI owners -.github/workflows/* @pavelreiter @LukasSanda +.github/workflows/* @pavelreiter @davidberry-nice @LukasSanda diff --git a/.github/workflows/deploy-javadoc.yml b/.github/workflows/deploy-javadoc.yml index 1d6ad742..418f5fba 100644 --- a/.github/workflows/deploy-javadoc.yml +++ b/.github/workflows/deploy-javadoc.yml @@ -14,16 +14,16 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: "17" distribution: "temurin" # aka adopt - name: Set up Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 - name: Run build run: ./gradlew dokkaHtmlMultiModule -Pandroid.experimental.settings.executionProfile=ci @@ -31,7 +31,7 @@ jobs: GPR_USERNAME: ${{ github.repository_owner }} GPR_TOKEN: ${{ github.token }} - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: javadoc-html path: dist @@ -42,7 +42,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Prepare Repository run: | @@ -50,12 +50,12 @@ jobs: rm -rf .github .idea .gitignore .chglog git checkout --orphan static-feature/pages - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: javadoc-html - name: Add & Commit - uses: EndBug/add-and-commit@v9.1.3 + uses: EndBug/add-and-commit@v9.1.4 with: push: origin static-feature/pages --force message: 'Documentation' diff --git a/.github/workflows/deploy-tag.yml b/.github/workflows/deploy-tag.yml index f050ad1e..08379a8d 100644 --- a/.github/workflows/deploy-tag.yml +++ b/.github/workflows/deploy-tag.yml @@ -17,16 +17,16 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: "17" distribution: "temurin" # aka adopt - name: Set up Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 - name: Append Version run: | diff --git a/.gitignore b/.gitignore index 3fff2ae4..f876ba48 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /local.properties /.idea/ !/.idea/codeStyles +!/.idea/copyright +!/.idea/scopes .DS_Store /build /captures diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b9c15f..457e8bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,53 @@ ## [Unreleased] +## [Unreleased] +### Bug Fixes +- Set CustomerId type to String +- consolidate differing kotlin tool versions on 1.9.21 +- Make date formatting thread safe +- Use referential equality for enum comparison +- Cancel start job in case of re-configuration +- Update Chat to separate prepare and connect actions. +- Display sender name and read/received status. +- fix TreeField/CVHierarchicalField to not skip every other level. +- Update Agent.isTyping only when agent is typing +- Remove messages with duplicate id from thread +- fix crash when restore suspended login dialog +- Sockets created by SDK are tagged for TrafficStats +- Delay SharedPreferences initialization +- Fix unreliable unit tests depending on makeMessageModel being sequential +- fix display of new agent messages +- Disable sending empty messages. ([#454](https://github.com/nice-devone/nice-cxone-mobile-sdk-android/issues/454)) +- return error when receiving "invalid" server response on image upload +- BREAKING CHANGE if uploaded filename has no extension, get one using MimeTypeMap +- Fix crashes on some Qualcomm/Samsung devices + + +### Dependency Change +- Update Kotlin 1.9.20 -> 1.9.21 +- Bump com.squareup.okhttp3:okhttp from 4.11.0 to 4.12.0 +- Bump androidx.core:core-ktx from 1.10.1 to 1.12.0 +- Update Kotlin 1.8.21 -> 1.9.10 + + +### Features +- Add ProxyLogger constructor with vararg param +- Improve Java compatibility +- Raise project compileSdk 33 -> 34 +- Improve logging of outgoing events +- Pass server reported errors to integration +- Process events on background thread +- Allow to change users name +- Message is updated when read by agent +- Add seenAt and inferred state to message metadata +- Add logger-android module +- Extract logging library module +- Add option to specify Logger for the SDK +- Correct welcome message handling +- replace dagger/hilt with Koin in UI and sample application components +- Implement CaseStatusChanged event + ## [1.2.1] diff --git a/build.gradle b/build.gradle index e0fd251a..7eb8dab3 100644 --- a/build.gradle +++ b/build.gradle @@ -8,17 +8,20 @@ plugins { id "com.android.library" version "$androidGradlePluginVersion" apply false id "com.android.application" version "$androidGradlePluginVersion" apply false id "org.jetbrains.dokka" version "$dokkaVersion" apply true - id "com.vanniktech.maven.publish" version "0.25.3" apply false - id "com.google.gms.google-services" version "4.3.15" apply false - id "androidx.navigation.safeargs" version "2.6.0" apply false + id "com.vanniktech.maven.publish" version "0.27.0" apply false + id "com.google.gms.google-services" version "4.4.1" apply false + id "androidx.navigation.safeargs" version "2.7.7" apply false id "io.gitlab.arturbosch.detekt" version "$detektVersion" - id "nl.neotech.plugin.rootcoverage" version "1.6.0" apply false + id "nl.neotech.plugin.rootcoverage" version "1.7.1" apply false id "com.dipien.semantic-version" version "2.0.0" apply false - id "com.google.dagger.hilt.android" version "$daggerHiltVersion" apply false + id "com.google.firebase.appdistribution" version "4.0.1" apply false + id 'com.google.firebase.crashlytics' version '2.9.9' apply false + id "com.google.devtools.ksp" version "1.9.22-1.0.17" apply false + id "me.tylerbwong.gradle.metalava" version "0.3.5" apply false } group = GROUP -version = "1.2.0" // Fallback version +version = "1.3.0" // Fallback version allprojects { group = rootProject.group @@ -34,6 +37,14 @@ tasks.dokkaHtmlMultiModule.configure { outputDirectory.set(project.file("dist")) } +tasks.register('metalavaGenerateSignature') { + dependsOn subprojects.collect { it.tasks.matching { it.name == "metalavaGenerateSignatureRelease" } } +} + +tasks.register('metalavaCheckCompatibility') { + dependsOn subprojects.collect { it.tasks.matching { it.name == "metalavaCheckCompatibilityRelease" } } +} + // Disabled until the support for AGP 8 is resolved - https://github.com/NeoTech-Software/Android-Root-Coverage-Plugin/issues/82 //rootCoverage { // generateXml true diff --git a/buildSrc/src/main/groovy/android-application-conventions.gradle b/buildSrc/src/main/groovy/android-application-conventions.gradle index fde995d1..f93306a4 100644 --- a/buildSrc/src/main/groovy/android-application-conventions.gradle +++ b/buildSrc/src/main/groovy/android-application-conventions.gradle @@ -5,7 +5,12 @@ plugins { android { defaultConfig { - targetSdk 33 + targetSdk 34 } + packagingOptions { + dex { + useLegacyPackaging false + } + } } diff --git a/buildSrc/src/main/groovy/android-docs-conventions.gradle b/buildSrc/src/main/groovy/android-docs-conventions.gradle new file mode 100644 index 00000000..b5c5c401 --- /dev/null +++ b/buildSrc/src/main/groovy/android-docs-conventions.gradle @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + +plugins { + id "docs-conventions" +} + +dokkaHtml { + configure { + dokkaSourceSets { + named("main") { + noAndroidSdkLink.set(false) + } + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/di/ServiceBindings.kt b/buildSrc/src/main/groovy/android-kotlin-conventions.gradle similarity index 57% rename from chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/di/ServiceBindings.kt rename to buildSrc/src/main/groovy/android-kotlin-conventions.gradle index babda3a4..30251172 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/di/ServiceBindings.kt +++ b/buildSrc/src/main/groovy/android-kotlin-conventions.gradle @@ -13,18 +13,25 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -package com.nice.cxonechat.ui.di +plugins { + id "kotlin-android" + id "kotlin-conventions" +} + +android { + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } -import com.nice.cxonechat.ui.data.PinpointPushMessageParser -import com.nice.cxonechat.ui.domain.PushMessageParser -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ServiceComponent + kotlinOptions { + jvmTarget = "11" + } + +} -@Module -@InstallIn(ServiceComponent::class) -internal interface ServiceBindings { - @Binds - fun pushParser(pinpointPushMessageParser: PinpointPushMessageParser): PushMessageParser +kotlin { + // Required to make kapt use same JVM target as compiler + jvmToolchain(11) } diff --git a/buildSrc/src/main/groovy/android-library-conventions.gradle b/buildSrc/src/main/groovy/android-library-conventions.gradle index 245fb924..9edab511 100644 --- a/buildSrc/src/main/groovy/android-library-conventions.gradle +++ b/buildSrc/src/main/groovy/android-library-conventions.gradle @@ -5,6 +5,6 @@ plugins { android { defaultConfig { - targetSdk 33 + targetSdk 34 } } diff --git a/buildSrc/src/main/groovy/android-library-style-conventions.gradle b/buildSrc/src/main/groovy/android-library-style-conventions.gradle new file mode 100644 index 00000000..6ba72ce6 --- /dev/null +++ b/buildSrc/src/main/groovy/android-library-style-conventions.gradle @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + +plugins { + id "kotlin-android" + id "library-style-conventions" +} + +android { + lint { + htmlReport true + + enable "CheckResult" + baseline = file("lint-baseline.xml") + } +} diff --git a/buildSrc/src/main/groovy/android-test-conventions.gradle b/buildSrc/src/main/groovy/android-test-conventions.gradle new file mode 100644 index 00000000..eda976a3 --- /dev/null +++ b/buildSrc/src/main/groovy/android-test-conventions.gradle @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + +plugins { + id "test-conventions" +} + +android { + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + debug { + enableUnitTestCoverage true + } + } +} + +dependencies { + androidTestImplementation "androidx.test:runner:1.5.2" + androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" +} diff --git a/buildSrc/src/main/groovy/android-ui-conventions.gradle b/buildSrc/src/main/groovy/android-ui-conventions.gradle index 4d58a49f..daf97aee 100644 --- a/buildSrc/src/main/groovy/android-ui-conventions.gradle +++ b/buildSrc/src/main/groovy/android-ui-conventions.gradle @@ -14,16 +14,14 @@ android { dependencies { implementation "androidx.appcompat:appcompat:1.6.1" - implementation "androidx.core:core-ktx:1.10.1" + implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.datastore:datastore-preferences:1.0.0" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" //Navigation - def axNavigationVersion = "2.6.0" + def axNavigationVersion = "2.7.3" implementation "androidx.navigation:navigation-fragment-ktx:$axNavigationVersion" implementation "androidx.navigation:navigation-ui-ktx:$axNavigationVersion" implementation "androidx.navigation:navigation-runtime-ktx:$axNavigationVersion" diff --git a/buildSrc/src/main/groovy/api-conventions.gradle b/buildSrc/src/main/groovy/api-conventions.gradle new file mode 100644 index 00000000..2b98f686 --- /dev/null +++ b/buildSrc/src/main/groovy/api-conventions.gradle @@ -0,0 +1,8 @@ +plugins { + id "me.tylerbwong.gradle.metalava" +} + +metalava { + reportWarningsAsErrors.set(true) + reportLintsAsErrors.set(true) +} diff --git a/buildSrc/src/main/groovy/docs-conventions.gradle b/buildSrc/src/main/groovy/docs-conventions.gradle index dc741873..fc731469 100644 --- a/buildSrc/src/main/groovy/docs-conventions.gradle +++ b/buildSrc/src/main/groovy/docs-conventions.gradle @@ -1,17 +1,22 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + plugins { id "org.jetbrains.dokka" } -dokkaHtml { - configure { - dokkaSourceSets { - named("main") { - noAndroidSdkLink.set(false) - } - } - } -} - dependencies { dokkaHtmlPlugin "org.jetbrains.dokka:kotlin-as-java-plugin:$dokkaVersion" } diff --git a/buildSrc/src/main/groovy/java-library-conventions.gradle b/buildSrc/src/main/groovy/java-library-conventions.gradle new file mode 100644 index 00000000..05bcaade --- /dev/null +++ b/buildSrc/src/main/groovy/java-library-conventions.gradle @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + +plugins { + id("java-library") +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} diff --git a/buildSrc/src/main/groovy/jvm-kotlin-conventions.gradle b/buildSrc/src/main/groovy/jvm-kotlin-conventions.gradle new file mode 100644 index 00000000..002f9403 --- /dev/null +++ b/buildSrc/src/main/groovy/jvm-kotlin-conventions.gradle @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + +plugins { + id("org.jetbrains.kotlin.jvm") +} + +kotlin { + jvmToolchain(11) +} diff --git a/buildSrc/src/main/groovy/kapt-conventions.gradle b/buildSrc/src/main/groovy/kapt-conventions.gradle deleted file mode 100644 index b8f749a2..00000000 --- a/buildSrc/src/main/groovy/kapt-conventions.gradle +++ /dev/null @@ -1,8 +0,0 @@ -plugins { - id "kotlin-kapt" -} - -// Allow references to generated code -kapt { - correctErrorTypes = true -} diff --git a/buildSrc/src/main/groovy/koin-conventions.gradle b/buildSrc/src/main/groovy/koin-conventions.gradle new file mode 100644 index 00000000..1beb9b17 --- /dev/null +++ b/buildSrc/src/main/groovy/koin-conventions.gradle @@ -0,0 +1,11 @@ +dependencies { + def koinVersion = "3.5.1" + def koinAndroidVersion = "3.5.0" + def koinAnnotationsVersion = "1.3.0" + + implementation platform("io.insert-koin:koin-bom:$koinVersion") + implementation "io.insert-koin:koin-android:$koinAndroidVersion" + + implementation "io.insert-koin:koin-annotations:$koinAnnotationsVersion" + ksp "io.insert-koin:koin-ksp-compiler:$koinAnnotationsVersion" +} diff --git a/buildSrc/src/main/groovy/kotlin-conventions.gradle b/buildSrc/src/main/groovy/kotlin-conventions.gradle index 25c89a46..5e480225 100644 --- a/buildSrc/src/main/groovy/kotlin-conventions.gradle +++ b/buildSrc/src/main/groovy/kotlin-conventions.gradle @@ -1,28 +1,21 @@ -plugins { - id "kotlin-android" -} - -android { - - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = "11" - } - -} - -kotlin { - // Required to make kapt use same JVM target as compiler - jvmToolchain(11) -} +/* + * Copyright (c) 2021-2023. 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. + */ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib" - implementation "org.jetbrains:annotations:13.0" + implementation "org.jetbrains:annotations:23.0.0" } diff --git a/buildSrc/src/main/groovy/ksp-conventions.gradle b/buildSrc/src/main/groovy/ksp-conventions.gradle new file mode 100644 index 00000000..0d141ef4 --- /dev/null +++ b/buildSrc/src/main/groovy/ksp-conventions.gradle @@ -0,0 +1,3 @@ +plugins { + id "com.google.devtools.ksp" +} diff --git a/buildSrc/src/main/groovy/library-style-conventions.gradle b/buildSrc/src/main/groovy/library-style-conventions.gradle index 0bd49fe2..1eae3bdc 100644 --- a/buildSrc/src/main/groovy/library-style-conventions.gradle +++ b/buildSrc/src/main/groovy/library-style-conventions.gradle @@ -1,6 +1,20 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + plugins { id "io.gitlab.arturbosch.detekt" - id "kotlin-android" } detekt { @@ -12,14 +26,6 @@ detekt { ignoredBuildTypes = ["release"] } -android { - lint { - htmlReport true - - enable "CheckResult" - } -} - dependencies { detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:$detektVersion" detektPlugins "io.gitlab.arturbosch.detekt:detekt-rules-libraries:$detektVersion" diff --git a/buildSrc/src/main/groovy/test-conventions.gradle b/buildSrc/src/main/groovy/test-conventions.gradle index 34c19232..0640a11d 100644 --- a/buildSrc/src/main/groovy/test-conventions.gradle +++ b/buildSrc/src/main/groovy/test-conventions.gradle @@ -1,26 +1,22 @@ -android { - defaultConfig { - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes { - debug { - enableUnitTestCoverage true - } - } -} +/* + * Copyright (c) 2021-2023. 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. + */ dependencies { - + testImplementation "io.github.diareuse:strucut:[1,2[" + testImplementation "io.mockk:mockk:1.13.8" testImplementation "junit:junit:4.13.2" - testImplementation "org.mockito:mockito-core:3.12.4" - testImplementation "org.mockito.kotlin:mockito-kotlin:3.2.0" - testImplementation "org.mockito:mockito-inline:3.11.2" - testImplementation "io.mockk:mockk:1.13.5" - testImplementation 'org.jetbrains.kotlin:kotlin-test-junit' testImplementation "org.jetbrains.kotlin:kotlin-reflect" - testImplementation "io.github.diareuse:strucut:[1,2[" - - androidTestImplementation "com.android.support.test:runner:1.0.2" - androidTestImplementation "com.android.support.test.espresso:espresso-core:3.0.2" - + testImplementation "org.jetbrains.kotlin:kotlin-test-junit" } diff --git a/buildSrc/src/main/groovy/ui-compose-conventions.gradle b/buildSrc/src/main/groovy/ui-compose-conventions.gradle index 459d693c..eb2573d0 100644 --- a/buildSrc/src/main/groovy/ui-compose-conventions.gradle +++ b/buildSrc/src/main/groovy/ui-compose-conventions.gradle @@ -4,20 +4,19 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.4.7" + kotlinCompilerExtensionVersion = "1.5.8" } } dependencies { // Compose implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1" - implementation platform('androidx.compose:compose-bom:2023.06.01') + implementation platform('androidx.compose:compose-bom:2023.10.01') implementation "androidx.activity:activity-compose:1.7.2" implementation "androidx.compose.material:material" implementation "androidx.compose.material:material-icons-extended" - implementation "androidx.compose.runtime:runtime-livedata" - implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.1" - implementation "androidx.compose.ui:ui:1.5.0-beta03" // Override to newer version + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.2" + implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-graphics" implementation "androidx.compose.ui:ui-tooling-preview" diff --git a/chat-sdk-core/api.txt b/chat-sdk-core/api.txt new file mode 100644 index 00000000..72690b6d --- /dev/null +++ b/chat-sdk-core/api.txt @@ -0,0 +1,1043 @@ +// Signature format: 4.0 +package com.nice.cxonechat { + + @com.nice.cxonechat.Public public interface Authorization { + method public default static operator com.nice.cxonechat.Authorization create(String code, String verifier); + method public String getCode(); + method public String getVerifier(); + property public abstract String code; + property public abstract String verifier; + field public static final com.nice.cxonechat.Authorization.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class Authorization.Companion { + method public operator com.nice.cxonechat.Authorization create(String code, String verifier); + } + + @com.nice.cxonechat.Public public fun interface Cancellable { + method public void cancel(); + field public static final com.nice.cxonechat.Cancellable.Companion Companion; + } + + public static final class Cancellable.Companion { + } + + @com.nice.cxonechat.Public public interface Chat extends java.lang.AutoCloseable { + method public com.nice.cxonechat.ChatActionHandler actions(); + method public void close(); + method public com.nice.cxonechat.Cancellable connect(); + method public com.nice.cxonechat.ChatFieldHandler customFields(); + method public com.nice.cxonechat.ChatEventHandler events(); + method public default com.nice.cxonechat.ChatMode getChatMode(); + method public com.nice.cxonechat.state.Configuration getConfiguration(); + method public com.nice.cxonechat.state.Environment getEnvironment(); + method public java.util.Collection getFields(); + method @Deprecated public com.nice.cxonechat.Cancellable reconnect(); + method public void setDeviceToken(String? token); + method public void setUserName(String firstName, String lastName); + method public void signOut(); + method public com.nice.cxonechat.ChatThreadsHandler threads(); + property public default com.nice.cxonechat.ChatMode chatMode; + property public abstract com.nice.cxonechat.state.Configuration configuration; + property public abstract com.nice.cxonechat.state.Environment environment; + property public abstract java.util.Collection fields; + } + + @com.nice.cxonechat.Public public interface ChatActionHandler extends java.lang.AutoCloseable { + method public void close(); + method public void onPopup(com.nice.cxonechat.ChatActionHandler.OnPopupActionListener listener); + } + + @com.nice.cxonechat.Public public static fun interface ChatActionHandler.OnPopupActionListener { + method public void onShowPopup(java.util.Map variables, com.nice.cxonechat.analytics.ActionMetadata metadata); + } + + @com.nice.cxonechat.Public public interface ChatBuilder { + method @Deprecated @CheckResult public com.nice.cxonechat.Cancellable build(com.nice.cxonechat.ChatBuilder.OnChatBuiltCallback callback); + method @CheckResult public com.nice.cxonechat.Cancellable build(com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback resultCallback); + method public default static operator com.nice.cxonechat.ChatBuilder getDefault(android.content.Context context, com.nice.cxonechat.SocketFactoryConfiguration config); + method public default static operator com.nice.cxonechat.ChatBuilder getDefault(android.content.Context context, com.nice.cxonechat.SocketFactoryConfiguration config, optional com.nice.cxonechat.log.Logger logger); + method public com.nice.cxonechat.ChatBuilder setAuthorization(com.nice.cxonechat.Authorization authorization); + method public com.nice.cxonechat.ChatBuilder setChatStateListener(com.nice.cxonechat.ChatStateListener listener); + method public com.nice.cxonechat.ChatBuilder setDevelopmentMode(boolean enabled); + method public com.nice.cxonechat.ChatBuilder setDeviceToken(String token); + method public com.nice.cxonechat.ChatBuilder setUserName(String first, String last); + field public static final com.nice.cxonechat.ChatBuilder.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class ChatBuilder.Companion { + method public operator com.nice.cxonechat.ChatBuilder getDefault(android.content.Context context, com.nice.cxonechat.SocketFactoryConfiguration config); + method public operator com.nice.cxonechat.ChatBuilder getDefault(android.content.Context context, com.nice.cxonechat.SocketFactoryConfiguration config, optional com.nice.cxonechat.log.Logger logger); + } + + @com.nice.cxonechat.Public public static fun interface ChatBuilder.OnChatBuiltCallback { + method public void onChatBuilt(com.nice.cxonechat.Chat chat); + } + + @com.nice.cxonechat.Public public static fun interface ChatBuilder.OnChatBuiltResultCallback { + method public void onChatBuiltResult(Object chat); + } + + @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); + } + + @com.nice.cxonechat.Public public static fun interface ChatEventHandler.OnEventErrorListener { + method public void onError(com.nice.cxonechat.exceptions.CXOneException exception); + } + + @com.nice.cxonechat.Public public static fun interface ChatEventHandler.OnEventSentListener { + method public void onSent(); + } + + @com.nice.cxonechat.Public public final class ChatEventHandlerActions { + method @com.nice.cxonechat.Public public static void chatWindowOpen(com.nice.cxonechat.ChatEventHandler); + method @com.nice.cxonechat.Public public static void chatWindowOpen(com.nice.cxonechat.ChatEventHandler, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void chatWindowOpen(com.nice.cxonechat.ChatEventHandler, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void chatWindowOpen(com.nice.cxonechat.ChatEventHandler, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void conversion(com.nice.cxonechat.ChatEventHandler, String type, Number value); + method @com.nice.cxonechat.Public public static void conversion(com.nice.cxonechat.ChatEventHandler, String type, Number value, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void conversion(com.nice.cxonechat.ChatEventHandler, String type, Number value, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void conversion(com.nice.cxonechat.ChatEventHandler, String type, Number value, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void customVisitor(com.nice.cxonechat.ChatEventHandler, Object data); + method @com.nice.cxonechat.Public public static void customVisitor(com.nice.cxonechat.ChatEventHandler, Object data, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void customVisitor(com.nice.cxonechat.ChatEventHandler, Object data, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method public static void event(com.nice.cxonechat.ChatEventHandler, java.util.UUID id); + method public static void event(com.nice.cxonechat.ChatEventHandler, java.util.UUID id, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method public static void event(com.nice.cxonechat.ChatEventHandler, java.util.UUID id, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void pageView(com.nice.cxonechat.ChatEventHandler, String title, String uri); + method @com.nice.cxonechat.Public public static void pageView(com.nice.cxonechat.ChatEventHandler, String title, String uri, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void pageView(com.nice.cxonechat.ChatEventHandler, String title, String uri, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void pageView(com.nice.cxonechat.ChatEventHandler, String title, String uri, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void pageViewEnded(com.nice.cxonechat.ChatEventHandler, String title, String uri); + method @com.nice.cxonechat.Public public static void pageViewEnded(com.nice.cxonechat.ChatEventHandler, String title, String uri, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void pageViewEnded(com.nice.cxonechat.ChatEventHandler, String title, String uri, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void pageViewEnded(com.nice.cxonechat.ChatEventHandler, String title, String uri, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void proactiveActionClick(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data); + method @com.nice.cxonechat.Public public static void proactiveActionClick(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void proactiveActionClick(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void proactiveActionClick(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void proactiveActionDisplay(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data); + method @com.nice.cxonechat.Public public static void proactiveActionDisplay(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void proactiveActionDisplay(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void proactiveActionDisplay(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void proactiveActionFailure(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data); + method @com.nice.cxonechat.Public public static void proactiveActionFailure(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void proactiveActionFailure(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void proactiveActionFailure(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method @com.nice.cxonechat.Public public static void proactiveActionSuccess(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data); + method @com.nice.cxonechat.Public public static void proactiveActionSuccess(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date); + method @com.nice.cxonechat.Public public static void proactiveActionSuccess(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method @com.nice.cxonechat.Public public static void proactiveActionSuccess(com.nice.cxonechat.ChatEventHandler, com.nice.cxonechat.analytics.ActionMetadata data, optional java.util.Date date, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + method public static void refresh(com.nice.cxonechat.ChatEventHandler); + method public static void refresh(com.nice.cxonechat.ChatEventHandler, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener); + method public static void refresh(com.nice.cxonechat.ChatEventHandler, optional com.nice.cxonechat.ChatEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatEventHandler.OnEventErrorListener? errorListener); + field public static final com.nice.cxonechat.ChatEventHandlerActions INSTANCE; + } + + @com.nice.cxonechat.Public public interface ChatFieldHandler { + method @kotlin.jvm.Throws(exceptionClasses={InvalidCustomFieldValue::class, UndefinedCustomField::class}) public void add(java.util.Map fields) throws com.nice.cxonechat.exceptions.InvalidCustomFieldValue, com.nice.cxonechat.exceptions.UndefinedCustomField; + } + + @com.nice.cxonechat.Public public final class ChatInstanceProvider implements com.nice.cxonechat.ChatStateListener com.nice.cxonechat.log.LoggerScope { + method public void addListener(com.nice.cxonechat.ChatInstanceProvider.Listener listener); + method public void cancel(); + method public void close(); + method public void configure(android.content.Context context, kotlin.jvm.functions.Function1 actions); + method @kotlin.jvm.Throws(exceptionClasses=InvalidStateException::class) public void connect() throws com.nice.cxonechat.exceptions.InvalidStateException; + method public com.nice.cxonechat.Authorization? getAuthorization(); + method public com.nice.cxonechat.Chat? getChat(); + method public com.nice.cxonechat.ChatState getChatState(); + method public com.nice.cxonechat.SocketFactoryConfiguration? getConfiguration(); + method public boolean getDevelopmentMode(); + method public com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider? getDeviceTokenProvider(); + method public com.nice.cxonechat.log.Logger getIdentity(); + method public com.nice.cxonechat.log.Logger getLogger(); + method public com.nice.cxonechat.UserName? getUserName(); + method public void onChatRuntimeException(com.nice.cxonechat.exceptions.RuntimeChatException exception); + method public void onConnected(); + method public void onReady(); + method public void onUnexpectedDisconnect(); + method @kotlin.jvm.Throws(exceptionClasses=InvalidStateException::class) public void prepare(android.content.Context context) throws com.nice.cxonechat.exceptions.InvalidStateException; + method @kotlin.jvm.Throws(exceptionClasses=InvalidStateException::class) public void prepare(android.content.Context context, optional com.nice.cxonechat.SocketFactoryConfiguration? newConfig) throws com.nice.cxonechat.exceptions.InvalidStateException; + method @Deprecated @kotlin.jvm.Throws(exceptionClasses=InvalidStateException::class) public void reconnect() throws com.nice.cxonechat.exceptions.InvalidStateException; + method public void removeListener(com.nice.cxonechat.ChatInstanceProvider.Listener listener); + method public com.nice.cxonechat.ChatInstanceProvider setCustomerValues(java.util.Map values); + method public void setUserName(com.nice.cxonechat.UserName name); + method public void signOut(); + property public final com.nice.cxonechat.Authorization? authorization; + property public final com.nice.cxonechat.Chat? chat; + property public final com.nice.cxonechat.ChatState chatState; + property public final com.nice.cxonechat.SocketFactoryConfiguration? configuration; + property public final boolean developmentMode; + property public final com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider? deviceTokenProvider; + property public com.nice.cxonechat.log.Logger identity; + property public final com.nice.cxonechat.log.Logger logger; + property public final com.nice.cxonechat.UserName? userName; + field public static final com.nice.cxonechat.ChatInstanceProvider.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class ChatInstanceProvider.Companion { + method public com.nice.cxonechat.ChatInstanceProvider create(com.nice.cxonechat.SocketFactoryConfiguration? configuration); + method public com.nice.cxonechat.ChatInstanceProvider create(com.nice.cxonechat.SocketFactoryConfiguration? configuration, optional com.nice.cxonechat.Authorization? authorization); + method public com.nice.cxonechat.ChatInstanceProvider create(com.nice.cxonechat.SocketFactoryConfiguration? configuration, optional com.nice.cxonechat.Authorization? authorization, optional com.nice.cxonechat.UserName? userName); + method public com.nice.cxonechat.ChatInstanceProvider create(com.nice.cxonechat.SocketFactoryConfiguration? configuration, optional com.nice.cxonechat.Authorization? authorization, optional com.nice.cxonechat.UserName? userName, optional boolean developmentMode); + method public com.nice.cxonechat.ChatInstanceProvider create(com.nice.cxonechat.SocketFactoryConfiguration? configuration, optional com.nice.cxonechat.Authorization? authorization, optional com.nice.cxonechat.UserName? userName, optional boolean developmentMode, optional com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider? deviceTokenProvider); + method public com.nice.cxonechat.ChatInstanceProvider create(com.nice.cxonechat.SocketFactoryConfiguration? configuration, optional com.nice.cxonechat.Authorization? authorization, optional com.nice.cxonechat.UserName? userName, optional boolean developmentMode, optional com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider? deviceTokenProvider, optional com.nice.cxonechat.log.Logger logger); + method public com.nice.cxonechat.ChatInstanceProvider get(); + } + + @com.nice.cxonechat.Public public static interface ChatInstanceProvider.ConfigurationScope { + method public boolean getAuthenticationRequired(); + method public com.nice.cxonechat.Authorization? getAuthorization(); + method public com.nice.cxonechat.SocketFactoryConfiguration? getConfiguration(); + method public boolean getDevelopmentMode(); + method public com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider? getDeviceTokenProvider(); + method public com.nice.cxonechat.log.Logger getLogger(); + method public com.nice.cxonechat.UserName? getUserName(); + method public void setAuthorization(com.nice.cxonechat.Authorization?); + method public void setConfiguration(com.nice.cxonechat.SocketFactoryConfiguration?); + method public void setDevelopmentMode(boolean); + method public void setDeviceTokenProvider(com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider?); + method public void setLogger(com.nice.cxonechat.log.Logger); + method public void setUserName(com.nice.cxonechat.UserName?); + property public abstract boolean authenticationRequired; + property public abstract com.nice.cxonechat.Authorization? authorization; + property public abstract com.nice.cxonechat.SocketFactoryConfiguration? configuration; + property public abstract boolean developmentMode; + property public abstract com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider? deviceTokenProvider; + property public abstract com.nice.cxonechat.log.Logger logger; + property public abstract com.nice.cxonechat.UserName? userName; + } + + @com.nice.cxonechat.Public public static fun interface ChatInstanceProvider.DeviceTokenProvider { + method public void requestDeviceToken(kotlin.jvm.functions.Function1 onComplete); + } + + @com.nice.cxonechat.Public public static interface ChatInstanceProvider.Listener { + method public default void onChatChanged(com.nice.cxonechat.Chat? chat); + method public default void onChatRuntimeException(com.nice.cxonechat.exceptions.RuntimeChatException exception); + method public default void onChatStateChanged(com.nice.cxonechat.ChatState chatState); + } + + @com.nice.cxonechat.Public public enum ChatMode { + method public static com.nice.cxonechat.ChatMode valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.nice.cxonechat.ChatMode[] values(); + enum_constant public static final com.nice.cxonechat.ChatMode MULTI_THREAD; + enum_constant public static final com.nice.cxonechat.ChatMode SINGLE_THREAD; + } + + @com.nice.cxonechat.Public public enum ChatState { + method public static com.nice.cxonechat.ChatState valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.nice.cxonechat.ChatState[] values(); + enum_constant public static final com.nice.cxonechat.ChatState CONNECTED; + enum_constant public static final com.nice.cxonechat.ChatState CONNECTING; + enum_constant public static final com.nice.cxonechat.ChatState CONNECTION_LOST; + enum_constant public static final com.nice.cxonechat.ChatState INITIAL; + enum_constant public static final com.nice.cxonechat.ChatState PREPARED; + enum_constant public static final com.nice.cxonechat.ChatState PREPARING; + enum_constant public static final com.nice.cxonechat.ChatState READY; + } + + @com.nice.cxonechat.Public public interface ChatStateListener { + method public void onChatRuntimeException(com.nice.cxonechat.exceptions.RuntimeChatException exception); + method public void onConnected(); + method public void onReady(); + method public void onUnexpectedDisconnect(); + } + + @com.nice.cxonechat.Public public interface ChatThreadEventHandler { + method public void trigger(com.nice.cxonechat.event.thread.ChatThreadEvent event, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener? errorListener); + } + + @com.nice.cxonechat.Public public static fun interface ChatThreadEventHandler.OnEventErrorListener { + method public void onError(com.nice.cxonechat.exceptions.CXOneException exception); + } + + @com.nice.cxonechat.Public public static fun interface ChatThreadEventHandler.OnEventSentListener { + method public void onSent(); + } + + @com.nice.cxonechat.Public public final class ChatThreadEventHandlerActions { + method public static void archiveThread(com.nice.cxonechat.ChatThreadEventHandler); + method public static void archiveThread(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener); + method public static void archiveThread(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener? errorListener); + method public static void loadMetadata(com.nice.cxonechat.ChatThreadEventHandler); + method public static void loadMetadata(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener); + method public static void loadMetadata(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener? errorListener); + method public static void markThreadRead(com.nice.cxonechat.ChatThreadEventHandler); + method public static void markThreadRead(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener); + method public static void markThreadRead(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener? errorListener); + method public static void typingEnd(com.nice.cxonechat.ChatThreadEventHandler); + method public static void typingEnd(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener); + method public static void typingEnd(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener? errorListener); + method public static void typingStart(com.nice.cxonechat.ChatThreadEventHandler); + method public static void typingStart(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener); + method public static void typingStart(com.nice.cxonechat.ChatThreadEventHandler, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener? listener, optional com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener? errorListener); + field public static final com.nice.cxonechat.ChatThreadEventHandlerActions INSTANCE; + } + + @com.nice.cxonechat.Public public interface ChatThreadHandler { + method public com.nice.cxonechat.ChatFieldHandler customFields(); + method public com.nice.cxonechat.ChatThreadEventHandler events(); + method public com.nice.cxonechat.thread.ChatThread get(); + method @CheckResult public com.nice.cxonechat.Cancellable get(com.nice.cxonechat.ChatThreadHandler.OnThreadUpdatedListener listener); + method public com.nice.cxonechat.ChatThreadMessageHandler messages(); + method public void refresh(); + method public void setName(String name); + } + + @com.nice.cxonechat.Public public static fun interface ChatThreadHandler.OnThreadUpdatedListener { + method public void onUpdated(com.nice.cxonechat.thread.ChatThread thread); + } + + @com.nice.cxonechat.Public public interface ChatThreadMessageHandler { + method public void loadMore(); + method public void send(com.nice.cxonechat.message.OutboundMessage message, optional com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener? listener); + method public default void send(Iterable attachments, optional String message, optional String? postback, optional com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener? listener); + method public default void send(String message, optional String? postback, optional com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener? listener); + } + + @com.nice.cxonechat.Public public static interface ChatThreadMessageHandler.OnMessageTransferListener { + method public default static operator com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener createFrom(optional com.nice.cxonechat.ChatThreadMessageHandler.OnUUIDListener? onProcessed, optional com.nice.cxonechat.ChatThreadMessageHandler.OnUUIDListener? onSent); + method public default void onProcessed(java.util.UUID id); + method public default void onSent(java.util.UUID id); + field public static final com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class ChatThreadMessageHandler.OnMessageTransferListener.Companion { + method public operator com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener createFrom(optional com.nice.cxonechat.ChatThreadMessageHandler.OnUUIDListener? onProcessed, optional com.nice.cxonechat.ChatThreadMessageHandler.OnUUIDListener? onSent); + } + + @com.nice.cxonechat.Public public static fun interface ChatThreadMessageHandler.OnUUIDListener { + method public void onTriggered(java.util.UUID id); + } + + @com.nice.cxonechat.Public public interface ChatThreadsHandler { + method @kotlin.jvm.Throws(exceptionClasses={UnsupportedChannelConfigException::class, MissingThreadListFetchException::class, MissingPreChatCustomFieldsException::class, InvalidCustomFieldValue::class, UndefinedCustomField::class}) public default com.nice.cxonechat.ChatThreadHandler create() throws com.nice.cxonechat.exceptions.InvalidCustomFieldValue, com.nice.cxonechat.exceptions.MissingPreChatCustomFieldsException, com.nice.cxonechat.exceptions.MissingThreadListFetchException, com.nice.cxonechat.exceptions.UndefinedCustomField, com.nice.cxonechat.exceptions.UnsupportedChannelConfigException; + method @kotlin.jvm.Throws(exceptionClasses={UnsupportedChannelConfigException::class, MissingThreadListFetchException::class, MissingPreChatCustomFieldsException::class, InvalidCustomFieldValue::class, UndefinedCustomField::class}) public default com.nice.cxonechat.ChatThreadHandler create(java.util.Map customFields) throws com.nice.cxonechat.exceptions.InvalidCustomFieldValue, com.nice.cxonechat.exceptions.MissingPreChatCustomFieldsException, com.nice.cxonechat.exceptions.MissingThreadListFetchException, com.nice.cxonechat.exceptions.UndefinedCustomField, com.nice.cxonechat.exceptions.UnsupportedChannelConfigException; + method @kotlin.jvm.Throws(exceptionClasses={UnsupportedChannelConfigException::class, MissingThreadListFetchException::class, MissingPreChatCustomFieldsException::class, InvalidCustomFieldValue::class, UndefinedCustomField::class}) public com.nice.cxonechat.ChatThreadHandler create(java.util.Map customFields, kotlin.sequences.Sequence> preChatSurveyResponse) throws com.nice.cxonechat.exceptions.InvalidCustomFieldValue, com.nice.cxonechat.exceptions.MissingPreChatCustomFieldsException, com.nice.cxonechat.exceptions.MissingThreadListFetchException, com.nice.cxonechat.exceptions.UndefinedCustomField, com.nice.cxonechat.exceptions.UnsupportedChannelConfigException; + method @kotlin.jvm.Throws(exceptionClasses={UnsupportedChannelConfigException::class, MissingThreadListFetchException::class, MissingPreChatCustomFieldsException::class, InvalidCustomFieldValue::class, UndefinedCustomField::class}) public default com.nice.cxonechat.ChatThreadHandler create(kotlin.sequences.Sequence> preChatSurveyResponse) throws com.nice.cxonechat.exceptions.InvalidCustomFieldValue, com.nice.cxonechat.exceptions.MissingPreChatCustomFieldsException, com.nice.cxonechat.exceptions.MissingThreadListFetchException, com.nice.cxonechat.exceptions.UndefinedCustomField, com.nice.cxonechat.exceptions.UnsupportedChannelConfigException; + method public com.nice.cxonechat.prechat.PreChatSurvey? getPreChatSurvey(); + method public void refresh(); + method public com.nice.cxonechat.ChatThreadHandler thread(com.nice.cxonechat.thread.ChatThread thread); + method @CheckResult public com.nice.cxonechat.Cancellable threads(com.nice.cxonechat.ChatThreadsHandler.OnThreadsUpdatedListener listener); + property public abstract com.nice.cxonechat.prechat.PreChatSurvey? preChatSurvey; + } + + @com.nice.cxonechat.Public public static fun interface ChatThreadsHandler.OnThreadsUpdatedListener { + method public void onThreadsUpdated(java.util.List threads); + } + + @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.TYPEALIAS}) public @interface Public { + } + + @com.nice.cxonechat.Public public interface SocketFactoryConfiguration { + method public default static operator com.nice.cxonechat.SocketFactoryConfiguration create(com.nice.cxonechat.state.Environment environment, long brandId, String channelId); + method @Deprecated public default static operator com.nice.cxonechat.SocketFactoryConfiguration create(com.nice.cxonechat.state.Environment environment, long brandId, String channelId, optional String version); + method public long getBrandId(); + method public String getChannelId(); + method public com.nice.cxonechat.state.Environment getEnvironment(); + method @Deprecated public String getVersion(); + property public abstract long brandId; + property public abstract String channelId; + property public abstract com.nice.cxonechat.state.Environment environment; + property @Deprecated public abstract String version; + field public static final com.nice.cxonechat.SocketFactoryConfiguration.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class SocketFactoryConfiguration.Companion { + method public operator com.nice.cxonechat.SocketFactoryConfiguration create(com.nice.cxonechat.state.Environment environment, long brandId, String channelId); + method @Deprecated public operator com.nice.cxonechat.SocketFactoryConfiguration create(com.nice.cxonechat.state.Environment environment, long brandId, String channelId, optional String version); + } + + @com.nice.cxonechat.Public public interface UserName { + method public default static operator com.nice.cxonechat.UserName create(String lastName, String firstName); + method public String getFirstName(); + method public default String getFullName(); + method public String getLastName(); + method public default boolean getValid(); + property public abstract String firstName; + property public default String fullName; + property public abstract String lastName; + property public default boolean valid; + field public static final com.nice.cxonechat.UserName.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class UserName.Companion { + method public operator com.nice.cxonechat.UserName create(String lastName, String firstName); + method public com.nice.cxonechat.UserName getAnonymous(); + property public final com.nice.cxonechat.UserName Anonymous; + } + +} + +package com.nice.cxonechat.analytics { + + @com.nice.cxonechat.Public public sealed interface ActionMetadata { + } + +} + +package com.nice.cxonechat.enums { + + @com.nice.cxonechat.Public public enum CXOneEnvironment { + method public final com.nice.cxonechat.state.Environment! getValue(); + method public static com.nice.cxonechat.enums.CXOneEnvironment valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.nice.cxonechat.enums.CXOneEnvironment[] values(); + property public final com.nice.cxonechat.state.Environment! value; + enum_constant public static final com.nice.cxonechat.enums.CXOneEnvironment AU1; + enum_constant public static final com.nice.cxonechat.enums.CXOneEnvironment CA1; + enum_constant public static final com.nice.cxonechat.enums.CXOneEnvironment EU1; + enum_constant public static final com.nice.cxonechat.enums.CXOneEnvironment JP1; + enum_constant public static final com.nice.cxonechat.enums.CXOneEnvironment NA1; + enum_constant public static final com.nice.cxonechat.enums.CXOneEnvironment UK1; + } + +} + +package com.nice.cxonechat.event { + + @com.nice.cxonechat.Public public abstract sealed class ChatEvent { + } + + @com.nice.cxonechat.Public public final class CustomVisitorEvent extends com.nice.cxonechat.event.ChatEvent { + ctor public CustomVisitorEvent(Object data); + } + + @com.nice.cxonechat.Public public final class TriggerEvent extends com.nice.cxonechat.event.ChatEvent { + ctor public TriggerEvent(java.util.UUID id); + } + +} + +package com.nice.cxonechat.event.thread { + + @com.nice.cxonechat.Public public final class ArchiveThreadEvent extends com.nice.cxonechat.event.thread.ChatThreadEvent { + field public static final com.nice.cxonechat.event.thread.ArchiveThreadEvent INSTANCE; + } + + @com.nice.cxonechat.Public public abstract sealed class ChatThreadEvent { + } + + @com.nice.cxonechat.Public public final class LoadThreadMetadataEvent extends com.nice.cxonechat.event.thread.ChatThreadEvent { + field public static final com.nice.cxonechat.event.thread.LoadThreadMetadataEvent INSTANCE; + } + + @com.nice.cxonechat.Public public final class MarkThreadReadEvent extends com.nice.cxonechat.event.thread.ChatThreadEvent { + field public static final com.nice.cxonechat.event.thread.MarkThreadReadEvent INSTANCE; + } + + @com.nice.cxonechat.Public public final class TypingEndEvent extends com.nice.cxonechat.event.thread.ChatThreadEvent { + field public static final com.nice.cxonechat.event.thread.TypingEndEvent INSTANCE; + } + + @com.nice.cxonechat.Public public final class TypingStartEvent extends com.nice.cxonechat.event.thread.ChatThreadEvent { + field public static final com.nice.cxonechat.event.thread.TypingStartEvent INSTANCE; + } + +} + +package com.nice.cxonechat.exceptions { + + @com.nice.cxonechat.Public public final class AnalyticsEventDispatchException extends com.nice.cxonechat.exceptions.CXOneException { + ctor public AnalyticsEventDispatchException(String message, Throwable? throwable); + } + + @com.nice.cxonechat.Public public abstract sealed class CXOneException extends java.lang.Exception { + field public static final long serialVersionUID = -7049214473807003049L; // 0x9e2c28c8cf310257L + } + + @com.nice.cxonechat.Public public final class InternalError extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public final class InvalidCustomFieldValue extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public final class InvalidParameterException extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public final class InvalidStateException extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public final class MissingCustomerId extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public final class MissingPreChatCustomFieldsException extends com.nice.cxonechat.exceptions.CXOneException { + method public Iterable getMissing(); + property public final Iterable missing; + } + + @com.nice.cxonechat.Public public final class MissingThreadListFetchException extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public abstract sealed class RuntimeChatException extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public static final class RuntimeChatException.AttachmentUploadError extends com.nice.cxonechat.exceptions.RuntimeChatException { + method public String? getAttachmentName(); + property public final String? attachmentName; + } + + @com.nice.cxonechat.Public public static final class RuntimeChatException.AuthorizationError extends com.nice.cxonechat.exceptions.RuntimeChatException { + } + + @com.nice.cxonechat.Public public static final class RuntimeChatException.ServerCommunicationError extends com.nice.cxonechat.exceptions.RuntimeChatException { + } + + @com.nice.cxonechat.Public public final class UndefinedCustomField extends com.nice.cxonechat.exceptions.CXOneException { + } + + @com.nice.cxonechat.Public public final class UnsupportedChannelConfigException extends com.nice.cxonechat.exceptions.CXOneException { + } + +} + +package com.nice.cxonechat.message { + + @com.nice.cxonechat.Public public interface Action { + } + + @com.nice.cxonechat.Public public static interface Action.ReplyButton extends com.nice.cxonechat.message.Action { + method public String? getDescription(); + method public com.nice.cxonechat.message.Media? getMedia(); + method public String? getPostback(); + method public String getText(); + property public abstract String? description; + property public abstract com.nice.cxonechat.message.Media? media; + property public abstract String? postback; + property public abstract String text; + } + + @com.nice.cxonechat.Public public interface Attachment { + method public String getFriendlyName(); + method public String? getMimeType(); + method public String getUrl(); + property public abstract String friendlyName; + property public abstract String? mimeType; + property public abstract String url; + } + + @com.nice.cxonechat.Public public interface ContentDescriptor { + method public default static operator com.nice.cxonechat.message.ContentDescriptor create(android.net.Uri content, android.content.Context context, String mimeType, String fileName, optional String? friendlyName); + method public default static operator com.nice.cxonechat.message.ContentDescriptor create(byte[] content, String mimeType, String fileName, String? friendlyName); + method public com.nice.cxonechat.message.ContentDescriptor.DataSource getContent(); + method public String getFileName(); + method public String? getFriendlyName(); + method public String getMimeType(); + property public abstract com.nice.cxonechat.message.ContentDescriptor.DataSource content; + property public abstract String fileName; + property public abstract String? friendlyName; + property public abstract String mimeType; + field public static final com.nice.cxonechat.message.ContentDescriptor.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class ContentDescriptor.Companion { + method public operator com.nice.cxonechat.message.ContentDescriptor create(android.net.Uri content, android.content.Context context, String mimeType, String fileName, optional String? friendlyName); + method public operator com.nice.cxonechat.message.ContentDescriptor create(byte[] content, String mimeType, String fileName, String? friendlyName); + } + + @com.nice.cxonechat.Public public abstract static sealed class ContentDescriptor.DataSource { + } + + @com.nice.cxonechat.Public public interface Media { + method public String getFileName(); + method public String getMimeType(); + method public String getUrl(); + property public abstract String fileName; + property public abstract String mimeType; + property public abstract String url; + } + + @com.nice.cxonechat.Public public abstract sealed class Message { + method public abstract Iterable getAttachments(); + method public abstract com.nice.cxonechat.message.MessageAuthor? getAuthor(); + method public abstract java.util.Date getCreatedAt(); + method public abstract com.nice.cxonechat.message.MessageDirection getDirection(); + method public abstract String? getFallbackText(); + method public abstract java.util.UUID getId(); + method public abstract com.nice.cxonechat.message.MessageMetadata getMetadata(); + method public abstract java.util.UUID getThreadId(); + property public abstract Iterable attachments; + property public abstract com.nice.cxonechat.message.MessageAuthor? author; + property public abstract java.util.Date createdAt; + property public abstract com.nice.cxonechat.message.MessageDirection direction; + property public abstract String? fallbackText; + property public abstract java.util.UUID id; + property public abstract com.nice.cxonechat.message.MessageMetadata metadata; + property public abstract java.util.UUID threadId; + } + + @com.nice.cxonechat.Public public abstract static class Message.ListPicker extends com.nice.cxonechat.message.Message { + ctor public Message.ListPicker(); + method public abstract Iterable getActions(); + method public abstract String getText(); + method public abstract String getTitle(); + property public abstract Iterable actions; + property public abstract String text; + property public abstract String title; + } + + @com.nice.cxonechat.Public public abstract static class Message.Plugin extends com.nice.cxonechat.message.Message { + ctor public Message.Plugin(); + method public abstract com.nice.cxonechat.message.PluginElement? getElement(); + method public abstract String? getPostback(); + property public abstract com.nice.cxonechat.message.PluginElement? element; + property public abstract String? postback; + } + + @com.nice.cxonechat.Public public abstract static class Message.QuickReplies extends com.nice.cxonechat.message.Message { + ctor public Message.QuickReplies(); + method public abstract Iterable getActions(); + method public abstract String getTitle(); + property public abstract Iterable actions; + property public abstract String title; + } + + @com.nice.cxonechat.Public public abstract static class Message.RichLink extends com.nice.cxonechat.message.Message { + ctor public Message.RichLink(); + method public abstract com.nice.cxonechat.message.Media getMedia(); + method public abstract String getTitle(); + method public abstract String getUrl(); + property public abstract com.nice.cxonechat.message.Media media; + property public abstract String title; + property public abstract String url; + } + + @com.nice.cxonechat.Public public abstract static class Message.Text extends com.nice.cxonechat.message.Message { + ctor public Message.Text(); + method public abstract String getText(); + property public abstract String text; + } + + @com.nice.cxonechat.Public public abstract class MessageAuthor { + ctor public MessageAuthor(); + method public abstract String getFirstName(); + method public abstract String getId(); + method public abstract String? getImageUrl(); + method public abstract String getLastName(); + method public final String getName(); + 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; + } + + @com.nice.cxonechat.Public public enum MessageDirection { + method public static com.nice.cxonechat.message.MessageDirection valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.nice.cxonechat.message.MessageDirection[] values(); + enum_constant public static final com.nice.cxonechat.message.MessageDirection ToAgent; + enum_constant public static final com.nice.cxonechat.message.MessageDirection ToClient; + } + + @com.nice.cxonechat.Public public interface MessageMetadata { + method public java.util.Date? getReadAt(); + method public java.util.Date? getSeenAt(); + method public com.nice.cxonechat.message.MessageStatus getStatus(); + property public abstract java.util.Date? readAt; + property public abstract java.util.Date? seenAt; + property public abstract com.nice.cxonechat.message.MessageStatus status; + } + + @com.nice.cxonechat.Public public enum MessageStatus { + method public static com.nice.cxonechat.message.MessageStatus valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.nice.cxonechat.message.MessageStatus[] values(); + enum_constant public static final com.nice.cxonechat.message.MessageStatus FAILED_TO_DELIVER; + enum_constant public static final com.nice.cxonechat.message.MessageStatus READ; + enum_constant public static final com.nice.cxonechat.message.MessageStatus SEEN; + enum_constant public static final com.nice.cxonechat.message.MessageStatus SENDING; + enum_constant public static final com.nice.cxonechat.message.MessageStatus SENT; + } + + @com.nice.cxonechat.Public public interface OutboundMessage { + method public default static operator com.nice.cxonechat.message.OutboundMessage create(Iterable attachments); + method public default static operator com.nice.cxonechat.message.OutboundMessage create(Iterable attachments, optional String message); + method public default static operator com.nice.cxonechat.message.OutboundMessage create(Iterable attachments, optional String message, optional String? postback); + method public default static operator com.nice.cxonechat.message.OutboundMessage create(String message); + method public default static operator com.nice.cxonechat.message.OutboundMessage create(String message, optional String? postback); + method public Iterable getAttachments(); + method public String getMessage(); + method public String? getPostback(); + property public abstract Iterable attachments; + property public abstract String message; + property public abstract String? postback; + field public static final com.nice.cxonechat.message.OutboundMessage.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class OutboundMessage.Companion { + method public operator com.nice.cxonechat.message.OutboundMessage create(Iterable attachments); + method public operator com.nice.cxonechat.message.OutboundMessage create(Iterable attachments, optional String message); + method public operator com.nice.cxonechat.message.OutboundMessage create(Iterable attachments, optional String message, optional String? postback); + method public operator com.nice.cxonechat.message.OutboundMessage create(String message); + method public operator com.nice.cxonechat.message.OutboundMessage create(String message, optional String? postback); + } + + @com.nice.cxonechat.Public public abstract sealed class PluginElement { + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Button extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Button(); + method public abstract String? getDeepLink(); + method public abstract boolean getDisplayInApp(); + method public abstract String? getPostback(); + method public abstract String getText(); + property public abstract String? deepLink; + property public abstract boolean displayInApp; + property public abstract String? postback; + property public abstract String text; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Countdown extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Countdown(); + method public abstract java.util.Date getEndsAt(); + method public abstract boolean isExpired(); + property public abstract java.util.Date endsAt; + property public abstract boolean isExpired; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Custom extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Custom(); + method public abstract String? getFallbackText(); + method public abstract java.util.Map getVariables(); + property public abstract String? fallbackText; + property public abstract java.util.Map variables; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.File extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.File(); + method public abstract String getMimeType(); + method public abstract String getName(); + method public abstract String getUrl(); + property public abstract String mimeType; + property public abstract String name; + property public abstract String url; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Gallery extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Gallery(); + method public abstract Iterable getElements(); + property public abstract Iterable elements; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.InactivityPopup extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.InactivityPopup(); + method public abstract Iterable getButtons(); + method public abstract com.nice.cxonechat.message.PluginElement.Countdown getCountdown(); + method public abstract com.nice.cxonechat.message.PluginElement.Subtitle? getSubtitle(); + method public abstract Iterable getTexts(); + method public abstract com.nice.cxonechat.message.PluginElement.Title getTitle(); + property public abstract Iterable buttons; + property public abstract com.nice.cxonechat.message.PluginElement.Countdown countdown; + property public abstract com.nice.cxonechat.message.PluginElement.Subtitle? subtitle; + property public abstract Iterable texts; + property public abstract com.nice.cxonechat.message.PluginElement.Title title; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Menu extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Menu(); + method public abstract Iterable getButtons(); + method public abstract Iterable getFiles(); + method public abstract Iterable getSubtitles(); + method public abstract Iterable getTexts(); + method public abstract Iterable getTitles(); + property public abstract Iterable buttons; + property public abstract Iterable files; + property public abstract Iterable subtitles; + property public abstract Iterable texts; + property public abstract Iterable titles; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.QuickReplies extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.QuickReplies(); + method public abstract Iterable getButtons(); + method public abstract com.nice.cxonechat.message.PluginElement.Text? getText(); + property public abstract Iterable buttons; + property public abstract com.nice.cxonechat.message.PluginElement.Text? text; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.SatisfactionSurvey extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.SatisfactionSurvey(); + method public abstract com.nice.cxonechat.message.PluginElement.Button getButton(); + method public abstract String? getPostback(); + method public abstract com.nice.cxonechat.message.PluginElement.Text? getText(); + property public abstract com.nice.cxonechat.message.PluginElement.Button button; + property public abstract String? postback; + property public abstract com.nice.cxonechat.message.PluginElement.Text? text; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Subtitle extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Subtitle(); + method public abstract String getText(); + property public abstract String text; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Text extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Text(); + method public abstract com.nice.cxonechat.message.TextFormat getFormat(); + method public abstract String getText(); + method @Deprecated public abstract boolean isHtml(); + method @Deprecated public abstract boolean isMarkdown(); + property public abstract com.nice.cxonechat.message.TextFormat format; + property @Deprecated public abstract boolean isHtml; + property @Deprecated public abstract boolean isMarkdown; + property public abstract String text; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.TextAndButtons extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.TextAndButtons(); + method public abstract Iterable getButtons(); + method public abstract com.nice.cxonechat.message.PluginElement.Text getText(); + property public abstract Iterable buttons; + property public abstract com.nice.cxonechat.message.PluginElement.Text text; + } + + @com.nice.cxonechat.Public public abstract static class PluginElement.Title extends com.nice.cxonechat.message.PluginElement { + ctor public PluginElement.Title(); + method public abstract String getText(); + property public abstract String text; + } + + @com.nice.cxonechat.Public public enum TextFormat { + method public final String! getMimeType(); + method public final boolean isHtml(); + method public final boolean isMarkdown(); + method public static com.nice.cxonechat.message.TextFormat valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.nice.cxonechat.message.TextFormat[] values(); + property public final boolean isHtml; + property public final boolean isMarkdown; + property public final String! mimeType; + enum_constant public static final com.nice.cxonechat.message.TextFormat Html; + enum_constant public static final com.nice.cxonechat.message.TextFormat Markdown; + enum_constant public static final com.nice.cxonechat.message.TextFormat Plain; + } + +} + +package com.nice.cxonechat.prechat { + + @com.nice.cxonechat.Public public interface PreChatSurvey { + method public kotlin.sequences.Sequence getFields(); + method public String getName(); + property public abstract kotlin.sequences.Sequence fields; + property public abstract String name; + } + + @com.nice.cxonechat.Public public sealed interface PreChatSurveyResponse { + method public T getQuestion(); + method public R getResponse(); + property public abstract T question; + property public abstract R response; + } + + @com.nice.cxonechat.Public public static interface PreChatSurveyResponse.Hierarchy extends com.nice.cxonechat.prechat.PreChatSurveyResponse> { + method public default static operator com.nice.cxonechat.prechat.PreChatSurveyResponse.Hierarchy create(com.nice.cxonechat.state.FieldDefinition.Hierarchy question, com.nice.cxonechat.state.HierarchyNode response); + field public static final com.nice.cxonechat.prechat.PreChatSurveyResponse.Hierarchy.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class PreChatSurveyResponse.Hierarchy.Companion { + method public operator com.nice.cxonechat.prechat.PreChatSurveyResponse.Hierarchy create(com.nice.cxonechat.state.FieldDefinition.Hierarchy question, com.nice.cxonechat.state.HierarchyNode response); + } + + @com.nice.cxonechat.Public public static interface PreChatSurveyResponse.Selector extends com.nice.cxonechat.prechat.PreChatSurveyResponse { + method public default static operator com.nice.cxonechat.prechat.PreChatSurveyResponse.Selector create(com.nice.cxonechat.state.FieldDefinition.Selector question, com.nice.cxonechat.state.SelectorNode response); + field public static final com.nice.cxonechat.prechat.PreChatSurveyResponse.Selector.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class PreChatSurveyResponse.Selector.Companion { + method public operator com.nice.cxonechat.prechat.PreChatSurveyResponse.Selector create(com.nice.cxonechat.state.FieldDefinition.Selector question, com.nice.cxonechat.state.SelectorNode response); + } + + @com.nice.cxonechat.Public public static interface PreChatSurveyResponse.Text extends com.nice.cxonechat.prechat.PreChatSurveyResponse { + method public default static operator com.nice.cxonechat.prechat.PreChatSurveyResponse.Text create(com.nice.cxonechat.state.FieldDefinition.Text question, String response); + field public static final com.nice.cxonechat.prechat.PreChatSurveyResponse.Text.Companion Companion; + } + + @com.nice.cxonechat.Public public static final class PreChatSurveyResponse.Text.Companion { + method public operator com.nice.cxonechat.prechat.PreChatSurveyResponse.Text create(com.nice.cxonechat.state.FieldDefinition.Text question, String response); + } + +} + +package com.nice.cxonechat.state { + + @com.nice.cxonechat.Public public interface Configuration { + method public default boolean allowsFieldId(String fieldId); + method public default kotlin.sequences.Sequence getAllCustomFields(); + method public kotlin.sequences.Sequence getContactCustomFields(); + method public kotlin.sequences.Sequence getCustomerCustomFields(); + method public boolean getHasMultipleThreadsPerEndUser(); + method public boolean isAuthorizationEnabled(); + method public boolean isProactiveChatEnabled(); + property public default kotlin.sequences.Sequence allCustomFields; + property public abstract kotlin.sequences.Sequence contactCustomFields; + property public abstract kotlin.sequences.Sequence customerCustomFields; + property public abstract boolean hasMultipleThreadsPerEndUser; + property public abstract boolean isAuthorizationEnabled; + property public abstract boolean isProactiveChatEnabled; + } + + @com.nice.cxonechat.Public public interface Connection { + method public int getBrandId(); + method public String getChannelId(); + method public String? getCustomerId(); + method public com.nice.cxonechat.state.Environment getEnvironment(); + method public String getFirstName(); + method public String getLastName(); + method public java.util.UUID getVisitorId(); + property public abstract int brandId; + property public abstract String channelId; + property public abstract String? customerId; + property public abstract com.nice.cxonechat.state.Environment environment; + property public abstract String firstName; + property public abstract String lastName; + property public abstract java.util.UUID visitorId; + } + + @com.nice.cxonechat.Public public interface Environment { + method public String getBaseUrl(); + method public String getChatUrl(); + method public String getLocation(); + method public String getName(); + method public String getOriginHeader(); + method public String getSocketUrl(); + property public abstract String baseUrl; + property public abstract String chatUrl; + property public abstract String location; + property public abstract String name; + property public abstract String originHeader; + property public abstract String socketUrl; + } + + @com.nice.cxonechat.Public public interface FieldDefinition { + method public String getFieldId(); + method public String getLabel(); + method public boolean isRequired(); + method @kotlin.jvm.Throws(exceptionClasses=InvalidCustomFieldValue::class) public void validate(String value) throws com.nice.cxonechat.exceptions.InvalidCustomFieldValue; + property public abstract String fieldId; + property public abstract boolean isRequired; + property public abstract String label; + } + + @com.nice.cxonechat.Public public static interface FieldDefinition.Hierarchy extends com.nice.cxonechat.state.FieldDefinition { + method public kotlin.sequences.Sequence> getValues(); + property public abstract kotlin.sequences.Sequence> values; + } + + @com.nice.cxonechat.Public public static interface FieldDefinition.Selector extends com.nice.cxonechat.state.FieldDefinition { + method public kotlin.sequences.Sequence getValues(); + property public abstract kotlin.sequences.Sequence values; + } + + @com.nice.cxonechat.Public public static interface FieldDefinition.Text extends com.nice.cxonechat.state.FieldDefinition { + method public boolean isEMail(); + property public abstract boolean isEMail; + } + + public final class FieldDefinitionListKt { + method @com.nice.cxonechat.Public @kotlin.jvm.Throws(exceptionClasses=MissingPreChatCustomFieldsException::class) public static void checkRequired(kotlin.sequences.Sequence, java.util.Map values) throws com.nice.cxonechat.exceptions.MissingPreChatCustomFieldsException; + method @com.nice.cxonechat.Public public static boolean containsField(kotlin.sequences.Sequence, String fieldId); + method @com.nice.cxonechat.Public public static com.nice.cxonechat.state.FieldDefinition? lookup(kotlin.sequences.Sequence, String fieldId); + method @com.nice.cxonechat.Public @kotlin.jvm.Throws(exceptionClasses=UndefinedCustomField::class) public static void validate(kotlin.sequences.Sequence, java.util.Map values) throws com.nice.cxonechat.exceptions.UndefinedCustomField; + } + + @com.nice.cxonechat.Public public interface HierarchyNode { + method public kotlin.sequences.Sequence> getChildren(); + method public String getLabel(); + method public T getNodeId(); + method public boolean isLeaf(); + property public abstract kotlin.sequences.Sequence> children; + property public abstract boolean isLeaf; + property public abstract String label; + property public abstract T nodeId; + } + + public final class HierarchyNodeKt { + method @com.nice.cxonechat.Public public static com.nice.cxonechat.state.HierarchyNode? lookup(com.nice.cxonechat.state.HierarchyNode, T nodeId); + method @com.nice.cxonechat.Public public static com.nice.cxonechat.state.HierarchyNode? lookup(kotlin.sequences.Sequence>, T nodeId); + } + + @com.nice.cxonechat.Public public interface SelectorNode { + method public String getLabel(); + method public String getNodeId(); + property public abstract String label; + property public abstract String nodeId; + } + + public final class SelectorNodeKt { + method @com.nice.cxonechat.Public public static boolean contains(kotlin.sequences.Sequence, String nodeId); + method @com.nice.cxonechat.Public public static com.nice.cxonechat.state.SelectorNode? lookup(kotlin.sequences.Sequence, String nodeId); + } + +} + +package com.nice.cxonechat.thread { + + @com.nice.cxonechat.Public public abstract class Agent { + ctor public Agent(); + method 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 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 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 public abstract boolean isBotUser; + property public abstract boolean isSurveyUser; + property public abstract boolean isTyping; + property public abstract String lastName; + property public abstract String? nickname; + } + + @com.nice.cxonechat.Public public abstract class ChatThread { + ctor public ChatThread(); + method public abstract boolean getCanAddMoreMessages(); + method public abstract java.util.List getFields(); + method public final boolean getHasMoreMessagesToLoad(); + method public abstract java.util.UUID getId(); + method public abstract java.util.List getMessages(); + method public abstract String getScrollToken(); + method public abstract com.nice.cxonechat.thread.Agent? getThreadAgent(); + method public abstract String? getThreadName(); + method public abstract com.nice.cxonechat.thread.ChatThreadState getThreadState(); + property public abstract boolean canAddMoreMessages; + property public abstract java.util.List fields; + property public final boolean hasMoreMessagesToLoad; + property public abstract java.util.UUID id; + property public abstract java.util.List messages; + property public abstract String scrollToken; + property public abstract com.nice.cxonechat.thread.Agent? threadAgent; + property public abstract String? threadName; + property public abstract com.nice.cxonechat.thread.ChatThreadState threadState; + } + + @com.nice.cxonechat.Public public enum ChatThreadState { + method public static com.nice.cxonechat.thread.ChatThreadState valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.nice.cxonechat.thread.ChatThreadState[] values(); + enum_constant public static final com.nice.cxonechat.thread.ChatThreadState Loaded; + enum_constant public static final com.nice.cxonechat.thread.ChatThreadState Pending; + enum_constant public static final com.nice.cxonechat.thread.ChatThreadState Ready; + enum_constant public static final com.nice.cxonechat.thread.ChatThreadState Received; + } + + @com.nice.cxonechat.Public public interface CustomField { + method public String getId(); + method public java.util.Date getUpdatedAt(); + method public String getValue(); + property public abstract String id; + property public abstract java.util.Date updatedAt; + property public abstract String value; + } + +} + diff --git a/chat-sdk-core/build.gradle b/chat-sdk-core/build.gradle index 74395542..9e850848 100644 --- a/chat-sdk-core/build.gradle +++ b/chat-sdk-core/build.gradle @@ -1,14 +1,17 @@ plugins { id "android-library-conventions" - id "kotlin-conventions" - id "docs-conventions" - id "test-conventions" - id "library-style-conventions" + id "android-kotlin-conventions" + id "android-docs-conventions" + id "android-test-conventions" + id "android-library-style-conventions" id "publish-conventions" + id "api-conventions" +} +metalava { + hiddenPackages = ["com.nice.cxonechat.internal"] } - android { - namespace 'com.nice.cxonechat' + namespace 'com.nice.cxonechat.core' defaultConfig { consumerProguardFiles "consumer-rules.pro" @@ -34,15 +37,17 @@ android { } } - kotlinOptions { - freeCompilerArgs = ['-Xjvm-default=all'] - } - buildFeatures { buildConfig true // Library version is used for device fingerprint } } +kotlin { + compilerOptions { + freeCompilerArgs = ['-Xjvm-default=all'] + } +} + // Setup publishing of all library variants. // Dependant will get matching variant automatically (eg.: buildType:debug will get buildType:debug) // Alternatively they can provide transformation mapping. @@ -51,9 +56,12 @@ mavenPublishing { } dependencies { - implementation "androidx.core:core-ktx:1.10.1" + implementation "androidx.core:core-ktx:1.12.0" implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' - implementation 'com.squareup.okhttp3:okhttp:4.11.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation project(':utilities') + api project(':logger') + implementation project(':logger-android') } diff --git a/chat-sdk-core/gradle.properties b/chat-sdk-core/gradle.properties index 0d9d0781..b2e398f0 100644 --- a/chat-sdk-core/gradle.properties +++ b/chat-sdk-core/gradle.properties @@ -1 +1,16 @@ +# +# Copyright (c) 2021-2023. 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. +# + POM_ARTIFACT_ID=chat-core diff --git a/chat-sdk-core/lint-baseline.xml b/chat-sdk-core/lint-baseline.xml new file mode 100644 index 00000000..f32fed49 --- /dev/null +++ b/chat-sdk-core/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/chat-sdk-core/notes/CXOneChatError.kt b/chat-sdk-core/notes/CXOneChatError.kt deleted file mode 100644 index cb86d61b..00000000 --- a/chat-sdk-core/notes/CXOneChatError.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2021-2023. 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.enums - -/** - * The different types of errors that may be experienced. - */ -internal enum class CXOneChatError(val value: Exception) { - // When calling any method - - /** An attempt was made to use a method without connecting first. Make sure you call the `connect` method first. */ - NotConnected( - Exception("You are trying to call a method without connecting first. Make sure you call connect first.") - ), - - /** The provided attachment was unable to be sent. */ - AttachmentError(Exception("The provided attachment wasn't able to be sent.")), - - /** The server experienced an internal error and was unable to perform the action. */ - ServerError(Exception("Internal server error.")), - - // Errors when calling connect - - /** The WebSocket refused to connect. */ - WebSocketConnectionFailure( - Exception( - "Something went wrong and the WebSocket refused to connect. If you are providing your own chatURL or" + - " socketURL, double check that these URLs are correct." - ) - ), - - /** The customer could not be authorized anonymously. */ - AnonymousAuthorizationFailure(Exception("Something went wrong and the customer could not be authorized.")), - - /** The customer could not be authorized using the OAuth details configured on the channel. */ - OAuthAuthorizationFailure(Exception("Something went wrong and the channel configuration could not be retrieved.")), - - /** The auth code has not been set, but an attempt has been made to authorize. */ - MissingAuthCode( - Exception( - "You are trying to authorize a customer through OAuth, but haven’t provided the authorization code yet." + - " Make sure you call setAuthCode before calling connect." - ) - ), - - /** The returning customer could not be reconnected. */ - ReconnectFailure(Exception("Something went wrong and the returning customer could not be reconnected.")), - - /** The customer was successfully authorized, but an access token wasn't returned. */ - MissingAccessToken( - Exception("The customer was successfully authorized using OAuth, but an access token wasn’t returned.") - ), - - /** The customer could not be associated with a visitor. */ - CustomerVisitorAssociationFailure(Exception("The customer could not be successfully associated with a visitor.")), - - /** The request was invalid and couldn't be completed. */ - InvalidRequest(Exception("Could not make the request because the URL was malformed.")), - - InvalidOldestDate(Exception("No oldest message date is saved.")) -} diff --git a/chat-sdk-core/src/androidTest/java/com/nice/cxonechat/ChatBuilderIntegrationTest.kt b/chat-sdk-core/src/androidTest/java/com/nice/cxonechat/ChatBuilderIntegrationTest.kt index b5fefdc8..644a6544 100644 --- a/chat-sdk-core/src/androidTest/java/com/nice/cxonechat/ChatBuilderIntegrationTest.kt +++ b/chat-sdk-core/src/androidTest/java/com/nice/cxonechat/ChatBuilderIntegrationTest.kt @@ -1,20 +1,36 @@ +/* + * Copyright (c) 2021-2023. 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 -import androidx.test.InstrumentationRegistry +import android.app.Application +import androidx.test.core.app.ApplicationProvider import androidx.test.filters.SmallTest -import androidx.test.runner.AndroidJUnit4 +import com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback import com.nice.cxonechat.internal.model.EnvironmentInternal +import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.runner.RunWith import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.SECONDS -@RunWith(AndroidJUnit4::class) @SmallTest -class ChatBuilderIntegrationTest { +internal class ChatBuilderIntegrationTest { @Test fun connectsToServer() { - val context = InstrumentationRegistry.getContext() + val context = ApplicationProvider.getApplicationContext() val environment = EnvironmentInternal( name = "", location = "", @@ -25,14 +41,15 @@ class ChatBuilderIntegrationTest { ) val config = SocketFactoryConfiguration(environment, 6450, "chat_f62c9eaf-f030-4d0d-aa87-6e8a5aed3c55") val latch = CountDownLatch(1) - ChatBuilder(context, config) + val cancellable = ChatBuilder(context, config) .setDevelopmentMode(true) .setUserName("john", "doe") - .build { - it.close() + .build(resultCallback = { + it.getOrThrow().close() latch.countDown() - } - latch.await() + }) + assertTrue(latch.await(10, SECONDS)) + cancellable.cancel() } } diff --git a/chat-sdk-core/src/main/AndroidManifest.xml b/chat-sdk-core/src/main/AndroidManifest.xml index 5e7684f5..f47ad8a4 100644 --- a/chat-sdk-core/src/main/AndroidManifest.xml +++ b/chat-sdk-core/src/main/AndroidManifest.xml @@ -1,4 +1,19 @@ + + diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/Cancellable.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/Cancellable.kt index 24ded1d3..c0d666b9 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/Cancellable.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/Cancellable.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 import androidx.annotation.CheckResult diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/Chat.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/Chat.kt index 20ddfba3..51c92d66 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/Chat.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/Chat.kt @@ -1,5 +1,22 @@ +/* + * Copyright (c) 2021-2023. 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 +import com.nice.cxonechat.ChatMode.MULTI_THREAD +import com.nice.cxonechat.ChatMode.SINGLE_THREAD import com.nice.cxonechat.state.Configuration import com.nice.cxonechat.state.Environment import com.nice.cxonechat.thread.ChatThread @@ -47,6 +64,12 @@ interface Chat : AutoCloseable { */ val fields: Collection + /** + * Current mode of the chat. + */ + val chatMode: ChatMode + get() = if (configuration.hasMultipleThreadsPerEndUser) MULTI_THREAD else SINGLE_THREAD + /** * Sets device token (notification push token) to this instance and transmits it * to the server. It's not guaranteed that the token is delivered to the server @@ -89,11 +112,10 @@ interface Chat : AutoCloseable { /** * Closes the connection to the chat backend and removes all listeners, even those - * the client forgot to unregister. The instance is considered dead after calling - * this method. + * the client forgot to unregister. * - * Interacting with any handlers or methods in this class can lead to unspecified, - * untested and further unwanted behavior, this includes [reconnect] method. + * Further interaction with any handlers or methods other than [events()] or [connect()] in this class can lead + * to unspecified, untested and further unwanted behavior. */ override fun close() @@ -109,5 +131,26 @@ interface Chat : AutoCloseable { * * @return An instance of [Cancellable] which can be used to interrupt background operation. */ + @Deprecated("Deprecated, use connect() instead.", replaceWith = ReplaceWith("connect()")) fun reconnect(): Cancellable + + /** + * Attempts to connect the chat session using existing configuration, the attempt will be made on background thread. + * Successful connection will be announced to the [ChatStateListener.onConnected] which was + * supplied in [ChatBuilder]. + * If there is an issue during/after reconnection the [ChatStateListener.onUnexpectedDisconnect] will be called, + * it is responsibility of application to perform a repeated reconnection attempt, if it is desirable. + * Reconnect attempts should be performed only if the device is connected to the internet. + * If the repeated reconnection attempts are made, they should be called with exponential backoff, + * in order to prevent backend overload. + * + * @return An instance of [Cancellable] which can be used to interrupt background operation. + */ + fun connect(): Cancellable + + /** + * Attempts to change username if the channel configuration allows setting of the username. + * All subsequent events sent from chat will have new value filled out. + */ + fun setUserName(firstName: String, lastName: String) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatActionHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatActionHandler.kt index 1dd49ebb..7a9b432d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatActionHandler.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatActionHandler.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 import com.nice.cxonechat.analytics.ActionMetadata 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 c6013200..0932606d 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 @@ -19,11 +19,14 @@ import android.content.Context import androidx.annotation.CheckResult import com.nice.cxonechat.internal.ChatBuilderDefault import com.nice.cxonechat.internal.ChatBuilderLogging -import com.nice.cxonechat.internal.ChatBuilderRepeating +import com.nice.cxonechat.internal.ChatBuilderThreading import com.nice.cxonechat.internal.ChatEntrails import com.nice.cxonechat.internal.ChatEntrailsAndroid import com.nice.cxonechat.internal.socket.SocketFactory import com.nice.cxonechat.internal.socket.SocketFactoryDefault +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerNoop +import com.nice.cxonechat.utilities.TaggingSocketFactory import okhttp3.OkHttpClient /** @@ -76,10 +79,24 @@ interface ChatBuilder { fun setDeviceToken(token: String): ChatBuilder /** - * Builds an instance of chat asynchronously. It's guaranteed to retrieve an - * instance of the chat. The method continuously polls the server when failure - * occurs with exponential backoff where the base is equal to 2 seconds. All - * failures are logged if [setDevelopmentMode] is set. + * Build an instance of chat asynchronously. + * Previously this method guaranteed an instance to be returned via [callback], this is no + * longer the case. If there is an communication issue with the server, this method will throw a runtime exception. + */ + @CheckResult + @Deprecated( + message = "Please migrate to build method with OnChatBuildResultCallback", + replaceWith = ReplaceWith( + expression = "build(resultCallback = OnChatBuiltResultCallback { callback.onChatBuilt(it.getOrThrow()) })", + imports = ["com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback"] + ) + ) + fun build(callback: OnChatBuiltCallback): Cancellable + + /** + * Builds an instance of chat asynchronously. + * Any standard issue which may happen during will be reported as [IllegalStateException] in [Result.onFailure]. + * All failures are logged if [setDevelopmentMode] is set. * * If the instance is not retrieved within a reasonable amount of time, the * device is not connected to the internet, or the chat provider experiences @@ -89,9 +106,11 @@ interface ChatBuilder { * Can be called from any thread, but will change to non-main thread immediately. * * @see OnChatBuiltCallback.onChatBuilt + * + * @return A [Cancellable] which allows to cancel the asynchronous operation. */ @CheckResult - fun build(callback: OnChatBuiltCallback): Cancellable + fun build(resultCallback: OnChatBuiltResultCallback): Cancellable /** * Callback allowing to listen to chat instance provisioning. @@ -106,23 +125,47 @@ interface ChatBuilder { fun onChatBuilt(chat: Chat) } + /** + * Callback allowing to listen to chat instance provisioning. + */ + @Public + fun interface OnChatBuiltResultCallback { + /** + * Notifies the consumer if the chat instance preparation has succeeded and provides the instance in the + * case of the success. + * It's always called on the main thread. + */ + fun onChatBuiltResult(chat: Result) + } + @Public companion object { /** * Returns an instance of [ChatBuilder] with Android specific parameters. + * + * @param context The [Context] used for persistent storage of values by the SDK. + * @param config [SocketFactoryConfiguration] connection configuration of the chat. + * @param logger [Logger] which will be used by the builder and the SDK, default is no-op implementation. + * * @see build * @see OnChatBuiltCallback * @see OnChatBuiltCallback.onChatBuilt * */ @JvmName("getDefault") + @JvmOverloads + @JvmStatic operator fun invoke( context: Context, config: SocketFactoryConfiguration, + logger: Logger = LoggerNoop, ): ChatBuilder { val sharedClient = OkHttpClient() + .newBuilder() + .socketFactory(TaggingSocketFactory) + .build() val factory = SocketFactoryDefault(config, sharedClient) - val entrails = ChatEntrailsAndroid(context.applicationContext, factory, config, sharedClient) + val entrails = ChatEntrailsAndroid(context.applicationContext, factory, config, sharedClient, logger) return invoke( entrails = entrails, factory = factory @@ -137,7 +180,7 @@ interface ChatBuilder { var builder: ChatBuilder builder = ChatBuilderDefault(entrails, factory) builder = ChatBuilderLogging(builder, entrails) - builder = ChatBuilderRepeating(builder, entrails) + builder = ChatBuilderThreading(builder, entrails) return builder } } 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 e3959bb8..d2275bc4 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 @@ -17,6 +17,7 @@ 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 /** @@ -28,33 +29,44 @@ import com.nice.cxonechat.exceptions.MissingCustomerId interface ChatEventHandler { /** - * Sends an [event] to server without further delays. If sending of the - * event fails, the event is considered consumed anyway. + * Sends an [event] to server without further delays from background thread. + * If sending of the event fails, the event is considered consumed anyway. * * If the event is sent to the server (not to be confused with processed - * by the server), the [listener] is invoked. + * by the server), the [listener] is invoked (from background thread). * * @param event [ChatEvent] subclass which generates an event model. * @param listener nullable listener if the client wants to know when it was sent. - * - * @throws MissingCustomerId in case of internal invalid state of the SDK. - * @throws JsonIOException in case of internal SDK error during - * the events' serialization. + * @param errorListener An optional listener for errors encountered when handling the event. */ - @Throws( - MissingCustomerId::class, - JsonIOException::class - ) - fun trigger(event: ChatEvent, listener: OnEventSentListener? = null) + fun trigger(event: ChatEvent, listener: OnEventSentListener? = null, errorListener: OnEventErrorListener? = null) /** - * Listener to be notified when the triggered event is sent. + * Listener to be notified when the triggered event is considered sent. */ @Public fun interface OnEventSentListener { /** - * Notifies about event being sent to the server. + * Notifies about event being sent to the server, or if the sending has failed and event is considered consumed. + * Method will be invoked on main thread. */ fun onSent() } + + /** + * Listener which will be notified when the triggered event has failed with an error. + */ + @Public + fun interface OnEventErrorListener { + + /** + * Notifies about event the reason why the event wasn't sent successfully to the server. + * + * @param exception The cause of failure. Possible causes are: + * * [MissingCustomerId] in case of internal invalid state of the SDK. + * * [JsonIOException] in case of internal SDK error during + * the events' serialization. + */ + fun onError(exception: CXOneException) + } } 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 969dd684..9b4a0aae 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 @@ -15,6 +15,7 @@ package com.nice.cxonechat +import com.nice.cxonechat.ChatEventHandler.OnEventErrorListener import com.nice.cxonechat.ChatEventHandler.OnEventSentListener import com.nice.cxonechat.analytics.ActionMetadata import com.nice.cxonechat.event.ChatWindowOpenEvent @@ -46,7 +47,8 @@ object ChatEventHandlerActions { * window. * * @param date date of the event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -55,7 +57,8 @@ object ChatEventHandlerActions { fun ChatEventHandler.chatWindowOpen( date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(ChatWindowOpenEvent(date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(ChatWindowOpenEvent(date), listener, errorListener) /** * Send a conversion event to the analytics server. @@ -70,7 +73,8 @@ object ChatEventHandlerActions { * @param value application-specific value of the conversion. Typically this will * be the sale or subscription price. * @param date date of the conversion event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -81,7 +85,8 @@ object ChatEventHandlerActions { value: Number, date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(ConversionEvent(type, value, date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(ConversionEvent(type, value, date), listener, errorListener) /** * Send an arbitrary custom analytics event to the analytics server. @@ -90,7 +95,8 @@ object ChatEventHandlerActions { * visitor event. * * @param data data to be sent in the custom event. Must be json encodable. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -99,7 +105,8 @@ object ChatEventHandlerActions { fun ChatEventHandler.customVisitor( data: Any, listener: OnEventSentListener? = null, - ) = trigger(CustomVisitorEvent(data), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(CustomVisitorEvent(data), listener, errorListener) /** * send a page viewed event to the server. @@ -122,7 +129,8 @@ object ChatEventHandlerActions { * Examples might include "com.nice.cxonechat.sample://category/cellphones" or * "/details/4568". * @param date date of the event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -133,7 +141,8 @@ object ChatEventHandlerActions { uri: String, date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(PageViewEvent(title, uri, date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(PageViewEvent(title, uri, date), listener, errorListener) /** * send a page view ended event to the server when a previously viewed page @@ -149,7 +158,8 @@ object ChatEventHandlerActions { * Examples might include "com.nice.cxonechat.sample://category/cellphones" or * "/details/4568". * @param date date of the event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -160,7 +170,8 @@ object ChatEventHandlerActions { uri: String, date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(PageViewEndedEvent(title, uri, date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(PageViewEndedEvent(title, uri, date), listener, errorListener) /** * Send a proactive action click event to the analytics. @@ -170,7 +181,8 @@ object ChatEventHandlerActions { * * @param data [ActionMetadata] provided in [ChatActionHandler.onPopup] listener. * @param date date of the event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -180,7 +192,8 @@ object ChatEventHandlerActions { data: ActionMetadata, date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(ProactiveActionClickEvent(data, date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(ProactiveActionClickEvent(data, date), listener, errorListener) /** * Send a proactive action display event to the analytics. @@ -190,7 +203,8 @@ object ChatEventHandlerActions { * * @param data [ActionMetadata] provided in [ChatActionHandler.onPopup] listener. * @param date date of the event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -200,7 +214,8 @@ object ChatEventHandlerActions { data: ActionMetadata, date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(ProactiveActionDisplayEvent(data, date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(ProactiveActionDisplayEvent(data, date), listener, errorListener) /** * Send a proactive action failure event to the analytics. @@ -212,7 +227,8 @@ object ChatEventHandlerActions { * * @param data [ActionMetadata] provided in [ChatActionHandler.onPopup] listener. * @param date date of the event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -222,7 +238,8 @@ object ChatEventHandlerActions { data: ActionMetadata, date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(ProactiveActionFailureEvent(data, date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(ProactiveActionFailureEvent(data, date), listener, errorListener) /** * Send a proactive action success event to the analytics. @@ -233,7 +250,8 @@ object ChatEventHandlerActions { * * @param data [ActionMetadata] provided in [ChatActionHandler.onPopup] listener. * @param date date of the event. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -243,19 +261,22 @@ object ChatEventHandlerActions { data: ActionMetadata, date: Date = Date(), listener: OnEventSentListener? = null, - ) = trigger(ProactiveActionSuccessEvent(data, date), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(ProactiveActionSuccessEvent(data, date), listener, errorListener) /** * Refresh the authentication token associated with the chat. * - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @JvmStatic fun ChatEventHandler.refresh( listener: OnEventSentListener? = null, - ) = trigger(RefreshToken, listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(RefreshToken, listener, errorListener) /** * Trigger event specified in agent console or elsewhere as per your @@ -263,7 +284,8 @@ object ChatEventHandlerActions { * representative for more information. * * @param id ID of event to trigger per representative instructions. - * @param listener listener to be notified after the event has been sent. + * @param listener an optional listener to be notified after the event has been sent. + * @param errorListener an optional error listener to be notified about errors encountered when event is handled. * @see ChatEventHandler.trigger */ @JvmOverloads @@ -271,5 +293,6 @@ object ChatEventHandlerActions { fun ChatEventHandler.event( id: UUID, listener: OnEventSentListener? = null, - ) = trigger(TriggerEvent(id), listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(TriggerEvent(id), listener, errorListener) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatFieldHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatFieldHandler.kt index 98369b4f..add94915 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatFieldHandler.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatFieldHandler.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 import com.nice.cxonechat.exceptions.InvalidCustomFieldValue 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 c4726427..ca9aa412 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 @@ -18,10 +18,19 @@ package com.nice.cxonechat import android.content.Context import com.nice.cxonechat.ChatState.CONNECTED import com.nice.cxonechat.ChatState.CONNECTING -import com.nice.cxonechat.ChatState.CONNECTION_CLOSED import com.nice.cxonechat.ChatState.CONNECTION_LOST import com.nice.cxonechat.ChatState.INITIAL -import com.nice.cxonechat.state.lookup +import com.nice.cxonechat.ChatState.PREPARED +import com.nice.cxonechat.ChatState.PREPARING +import com.nice.cxonechat.ChatState.READY +import com.nice.cxonechat.exceptions.InvalidStateException +import com.nice.cxonechat.exceptions.RuntimeChatException +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerNoop +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.warning +import com.nice.cxonechat.state.containsField import com.nice.cxonechat.state.validate import java.lang.ref.WeakReference @@ -34,16 +43,23 @@ import java.lang.ref.WeakReference * @param developmentMode True if in development mode to get extra logging. * @param deviceTokenProvider Provider of device tokens for push messages, default implementation will * disable push notifications. + * @param logger The [Logger] used by the SDK, default is no-op implementation. + * @param chatBuilderProvider **INTERNAL USAGE ONLY** Provides [ChatBuilder]. For internal testing usage only. */ -@Suppress("TooManyFunctions") +@Suppress( + "TooManyFunctions", + "LongParameterList" +) @Public -class ChatInstanceProvider( +class ChatInstanceProvider private constructor( configuration: SocketFactoryConfiguration?, - authorization: Authorization? = null, - userName: UserName? = null, - developmentMode: Boolean = false, - deviceTokenProvider: DeviceTokenProvider? = null, -) : ChatStateListener { + authorization: Authorization?, + userName: UserName?, + developmentMode: Boolean, + deviceTokenProvider: DeviceTokenProvider?, + logger: Logger, + private val chatBuilderProvider: (Context, SocketFactoryConfiguration, Logger) -> ChatBuilder, +) : ChatStateListener, LoggerScope by LoggerScope(TAG, logger) { /** those interested in ChatInstanceProvider updates. */ @Public interface Listener { @@ -60,9 +76,15 @@ class ChatInstanceProvider( * @param chatState New chat state. */ fun onChatStateChanged(chatState: ChatState) {} + + /** + * Invoked when chat reports runtime exception which was encounter in background thread. + * @see [ChatStateListener.onChatRuntimeException]. + */ + fun onChatRuntimeException(exception: RuntimeChatException) {} } - /** Defines provider of device token for push notifications, typically this will [Firebase.messaging.token]. */ + /** Defines provider of device token for push notifications, typically this will be `Firebase.messaging.token`. */ @Public fun interface DeviceTokenProvider { /** @@ -93,6 +115,9 @@ class ChatInstanceProvider( /** Current deviceToken provider. */ var deviceTokenProvider: DeviceTokenProvider? + + /** Current [Logger]. */ + var logger: Logger } /** Current configuration. */ @@ -115,6 +140,13 @@ class ChatInstanceProvider( var deviceTokenProvider: DeviceTokenProvider? = deviceTokenProvider private set + /** Current [Logger]. */ + var logger: Logger = logger + private set + + override val identity: Logger + get() = logger + /** token provided by deviceTokenProvider. */ private var deviceToken: String? = null set(value) { @@ -122,12 +154,6 @@ class ChatInstanceProvider( chat?.setDeviceToken(value) } - /** Cancellable creating chat instance. */ - private var createJob: Cancellable? = null - - /** Cancellable establishing new chat connection. */ - private var reconnectJob: Cancellable? = null - /** List of listeners to be notified. */ private var listeners = listOf>() @@ -136,18 +162,20 @@ class ChatInstanceProvider( private set(value) { if (field != value) { field = value - eachListener { onChatChanged(field) } + eachListener(value, Listener::onChatChanged) } } + private data class ChatStateInternal( + val state: ChatState, + val cancellable: Cancellable? = null, + ) + + private var state = ChatStateInternal(INITIAL) + /** Current chat state. */ - var chatState: ChatState = INITIAL - private set(value) { - if (field != value) { - field = value - eachListener { onChatStateChanged(value) } - } - } + val chatState: ChatState + get() = state.state /** * Add a listener to receive notifications of chat and state changes. @@ -167,71 +195,161 @@ class ChatInstanceProvider( * @param listener Listener to remove. */ fun removeListener(listener: Listener) { - listeners = listeners.filter { it !== listener } + listeners = listeners.filter { it.get() !== listener } } + private fun assertState(state: (ChatState) -> Boolean, generator: () -> String) { + if (!state(chatState)) { + throw InvalidStateException(generator()) + } + } + + private fun assertState(state: ChatState, generator: () -> String) = + assertState({ it === state }, generator) + /** * Reestablish a chat connection if one does not currently exist. * @param context Application context for resource access. + * @param newConfig Optional configuration which will be used to prepare [Chat] instance. If supplied, it will take precedence over + * previously set configuration. + * @throws InvalidStateException if the connection is not in the initial state, i.e.: + * * it has already been prepared or connected; + * * the [ChatInstanceProvider] was not provided with a configuration at creation time. */ - fun start(context: Context) { - if (setOf(CONNECTED, CONNECTING).contains(chatState)) { - return + @Throws(InvalidStateException::class) + @JvmOverloads + fun prepare(context: Context, newConfig: SocketFactoryConfiguration? = null) = scope("prepare") { + if (state.state == PREPARED) { + warning("Ignoring prepare in PREPARED state") + return@scope } - configuration?.let { configuration -> - chatState = CONNECTING + assertState(INITIAL) { + "ChatInstanceProvider.prepare called in an incorrect state ($chatState). " + + "It is only valid from the INITIAL state." + } - createJob = ChatBuilder(context = context, config = configuration).apply { + val currentConfig = configuration + val configuration = newConfig ?: currentConfig ?: throw InvalidStateException( + "ChatInstanceProvider called with no valid configuration. Insure the ChatInstanceProvider is " + + "properly configured before calling prepare." + ) + + chatBuilderProvider(context, configuration, logger) + .setChatStateListener(this@ChatInstanceProvider) + .setDevelopmentMode(developmentMode) + .apply { userName?.run { setUserName(first = firstName, last = lastName) } + } + .apply { authorization?.let(::setAuthorization) - setDevelopmentMode(developmentMode) - setChatStateListener(this@ChatInstanceProvider) + } + .apply { deviceTokenProvider?.requestDeviceToken { token -> deviceToken = token setDeviceToken(token) } - }.build { result -> - chat = result - deviceToken?.let { chat?.setDeviceToken(it) } } - } + .build { result: Result -> + result.onSuccess { newChat -> + chat = newChat + advanceState(PREPARED) + deviceToken?.let { chat?.setDeviceToken(it) } + }.onFailure { + warning("Failed to prepare Chat", it) + chat = null + advanceState(INITIAL) + } + } + .also { + // if build is synchronous, the chat will have already advanced + // to PREPARED, so just skip PREPARING. + if (it != Cancellable.noop) { + advanceState(PREPARING, it) + } + } } /** - * Cancel a pending start request. + * Connect the chat web socket so chat functions are available. + * @throws InvalidStateException if the connection is not in the correct state: + * * it has not been prepared; + * * it is already connected or connecting. */ - fun cancelStart() { - createJob?.cancel() - createJob = null + @Throws(InvalidStateException::class) + fun connect() = scope("connect") { + if (state.state == CONNECTED) { + warning("Ignoring connect in CONNECTED state") + return@scope + } - chatState = INITIAL + assertState({ setOf(PREPARED, CONNECTION_LOST).contains(it) }) { + "ChatInstanceProvider.connect called in invalid state ($chatState). " + + "It is only allowed when the connection is either PREPARED or LOST_CONNECTION." + } + + doConnect() + } + + private fun doConnect() { + chat?.run { + val cancellable = connect() + + if (cancellable != Cancellable.noop) { + // if connect is synchronous skip CONNECTING state + advanceState(CONNECTING, cancellable = cancellable, cancel = false) + } + } } /** * Reconnect a broken connection. + * @throws InvalidStateException if the connection was not previously reported as lost + * or [connect] has already been called since it was reported lost. */ + @Throws(InvalidStateException::class) + @Deprecated( + "ChatInstanceProvider.reconnect() has been deprecated. Replace with ChatInstanceProvider.connect()", + replaceWith = ReplaceWith("connect()") + ) fun reconnect() { - reconnectJob = chat?.reconnect()?.also { - chatState = CONNECTING + assertState(CONNECTION_LOST) { + "ChatInstanceProvider.reconnect called in invalid state ($chatState). " + + "It is only allowed after when the connection has been closed by the server. " } + + doConnect() } /** - * Stop any existing chat attempts and reset the state to CONNECTION_CLOSED. + * Close any connected chat web sockets. + * + * After `close()` is called, only usage of [Chat.events] is allowed. + * + * The [state] is moved to [PREPARED]. */ - fun stop() { - reconnectJob?.cancel() - reconnectJob = null + fun close() { + chat?.close() - createJob?.cancel() - createJob = null + advanceState(PREPARED) + } - chat?.close() - chat = null - chatState = CONNECTION_CLOSED + /** + * Cancel any pending prepare or connect action and return the state + * to an appropriate starting point. + */ + fun cancel() { + when (chatState) { + INITIAL -> Unit + PREPARING -> advanceState(INITIAL) + PREPARED -> Unit + CONNECTING -> advanceState(PREPARED) + CONNECTED -> Unit + CONNECTION_LOST -> advanceState(PREPARED) + READY -> Unit + } } /** @@ -242,9 +360,10 @@ class ChatInstanceProvider( authorization = null userName = null - chatState = CONNECTION_CLOSED chat?.signOut() chat = null + + advanceState(INITIAL) } } @@ -260,9 +379,7 @@ class ChatInstanceProvider( fun setCustomerValues(values: Map) = apply { chat?.run { val customerCustomFields = configuration.customerCustomFields - val fields = values.filter { - customerCustomFields.lookup(it.key) != null - } + val fields = values.filterKeys(customerCustomFields::containsField) runCatching { customerCustomFields.validate(fields) } .onSuccess { @@ -274,7 +391,8 @@ class ChatInstanceProvider( /** * Update the configuration of chat. * - * 1. Stops any current chat. + * 1. Stops any current chat. This will result in discarding any stored [Authorization] + * or [UserName]. Any such details must be provided in the configuration block once again. * 2. Executes the configuration actions block. * 3. Restarts chat. * @@ -284,16 +402,14 @@ class ChatInstanceProvider( fun configure(context: Context, actions: ConfigurationScope.() -> Unit) { val provider = this - chat?.signOut() - chat = null - - object : ConfigurationScope { - override val authenticationRequired: Boolean - get() = provider.chat?.configuration?.isAuthorizationEnabled == true + val scope = object : ConfigurationScope { + override val authenticationRequired = provider.chat?.configuration?.isAuthorizationEnabled == true override var configuration: SocketFactoryConfiguration? get() = provider.configuration - set(value) { provider.configuration = value } + set(value) { + provider.configuration = value + } override var userName: UserName? get() = provider.userName @@ -318,50 +434,95 @@ class ChatInstanceProvider( set(value) { provider.deviceTokenProvider = value } - }.actions() - restart(context) + override var logger: Logger + get() = provider.logger + set(value) { + provider.logger = value + } + } + + signOut() + + scope.actions() + + prepare(context) + } + + @JvmSynthetic + internal fun advanceState(next: ChatState, cancellable: Cancellable? = null, cancel: Boolean = true) { + if (chatState != next) { + if (cancel) { + state.cancellable?.cancel() + } + + if (next in setOf(PREPARING, CONNECTING)) { + assert(cancellable != null) { + "Internal error: advanceState($next) requires a cancellable." + } + } else { + assert(cancellable == null) { + "Internal error: advanceState($next) prohibits a cancellable." + } + } + + state = ChatStateInternal(next, cancellable) + + eachListener(next, Listener::onChatStateChanged) + } } /** * Iterate over the list of listeners, forwarding the given * action or removing the listener if it's no longer valid. * + * @param T An object which is passed to all listeners as part of an [action]. + * @param actionParameter An object with will be passed to the action. * @param action Action to perform on each listener. */ - private fun eachListener(action: Listener.() -> Unit) { - listeners = listeners.filter { - it.get()?.run { - action() - true - } ?: false + private fun eachListener(actionParameter: T, action: Listener.(T) -> Unit) { + listeners = listeners.filter { listenerWeakReference -> + listenerWeakReference.get()?.also { listener -> listener.action(actionParameter) } != null } } - /** - * Stop any current connection under way and start a new connection attempt. - * - * @param context Application context for resource access. - */ - private fun restart(context: Context) { - stop() - start(context) - } - // // ChatStateListener Implementation // override fun onConnected() { - chatState = CONNECTED + advanceState(CONNECTED) + } + + override fun onReady() { + advanceState(READY) } override fun onUnexpectedDisconnect() { - chatState = CONNECTION_LOST + advanceState(CONNECTION_LOST) + } + + override fun onChatRuntimeException(exception: RuntimeChatException) { + eachListener(exception, Listener::onChatRuntimeException) + } + + /** + * Sets [UserName] which will be used during creation of [Chat] instance + * and will apply it to current instance of [Chat], if it exists. + * The username will be applied only if the chat channel configuration allows it. + * + * @param name A username which should be set. + * @see [Chat.setUserName]. + */ + fun setUserName(name: UserName) { + userName = name + chat?.setUserName(name.firstName, name.lastName) } @Public companion object { + private const val TAG = "ChatInstanceProvider" + @Suppress("LateinitUsage") private lateinit var instance: ChatInstanceProvider @@ -376,8 +537,12 @@ class ChatInstanceProvider( * @param userName Initial user name to use. * @param developmentMode True if in development mode to get extra logging. * @param deviceTokenProvider Provider of device tokens for push messages. + * @param logger [Logger] to be used by the ChatInstanceProvider and Chat. * @return the newly created ChatInstanceProvider singleton. */ + @Suppress( + "LongParameterList" // Most of the parameters have default values provided. + ) @JvmOverloads fun create( configuration: SocketFactoryConfiguration?, @@ -385,12 +550,47 @@ class ChatInstanceProvider( userName: UserName? = null, developmentMode: Boolean = false, deviceTokenProvider: DeviceTokenProvider? = null, + logger: Logger = LoggerNoop, + ) = create( + configuration, + authorization, + userName, + developmentMode, + deviceTokenProvider, + logger, + ChatBuilder.Companion::invoke, + ) + + /** + * Create the ChatInstanceProvider singleton. + * + * @param configuration Initial Sdk Configuration to use. + * @param authorization Initial authorization to use. + * @param userName Initial user name to use. + * @param developmentMode True if in development mode to get extra logging. + * @param deviceTokenProvider Provider of device tokens for push messages. + * @param logger [Logger] to be used by the ChatInstanceProvider and Chat. + * @param chatBuilderProvider **INTERNAL USAGE ONLY** Provides [ChatBuilder]. For internal testing usage only. + * @return the newly created ChatInstanceProvider singleton. + */ + @Suppress("LongParameterList") + @JvmSynthetic + internal fun create( + configuration: SocketFactoryConfiguration?, + authorization: Authorization? = null, + userName: UserName? = null, + developmentMode: Boolean = false, + deviceTokenProvider: DeviceTokenProvider? = null, + logger: Logger = LoggerNoop, + chatBuilderProvider: (Context, SocketFactoryConfiguration, Logger) -> ChatBuilder, ) = ChatInstanceProvider( configuration, authorization, userName, developmentMode, deviceTokenProvider, + logger, + chatBuilderProvider, ).also { instance = it } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatMode.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatMode.kt new file mode 100644 index 00000000..5ceaf2a0 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatMode.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021-2023. 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 + +/** Chat mode in use, only valid when state is connected. */ +@Public +enum class ChatMode { + /** + * Chat is single-threaded. + * + * Creating and archiving threads is not allowed. The single thread will be automatically + * created if necessary. + */ + SINGLE_THREAD, + + /** Chat is multi-threaded. */ + MULTI_THREAD, +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatState.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatState.kt index ba1395cc..09cf8a27 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatState.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatState.kt @@ -23,15 +23,40 @@ enum class ChatState { /** Not yet configured enough to connect. */ INITIAL, - /** in the process of connecting. */ + /** + * In the process of preparing the chat by performing initial configuration + * checks fetching the channel configuration. + */ + PREPARING, + + /** + * The chat is "prepared" but no web socket is open. + * + * In the prepared state it is acceptable to generate analytics events via + * [[ChatEventHandlerActions]] and to attempt to connect the web socket via + * [Chat.connect], but chat functionality via [Chat.threads] is + * unavailable. + */ + PREPARED, + + /** In the process of connecting the websocket. */ CONNECTING, - /** a connection has been established. */ + /** + * A websocket connection has been established. + * + * In the CONNECTED state it is acceptable to generate analytics events via + * [[ChatEventHandlerActions]] or to access chat functionality available via + * [Chat.threads]. + */ CONNECTED, + /** + * A chat state was recovered (if there was anything to recover). + * If there were any thread/s recovered, then this fact should have signaled via listener. + */ + READY, + /** the connection was involuntarily lost. */ CONNECTION_LOST, - - /** the connection was closed by the user. */ - CONNECTION_CLOSED } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatStateListener.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatStateListener.kt index 53f03c66..bcae04b2 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatStateListener.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatStateListener.kt @@ -1,5 +1,22 @@ +/* + * Copyright (c) 2021-2023. 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 +import com.nice.cxonechat.exceptions.RuntimeChatException + /** * Listener for [Chat] instance state changes. * The current main purpose of this listener is to provide callbacks which notify integrating application about @@ -25,4 +42,17 @@ interface ChatStateListener { * This happens once initial connection is established or after [Chat.reconnect] is called. */ fun onConnected() + + /** + * Method is invoked when chat instance has finished performing background tasks after connection was established. + */ + fun onReady() + + /** + * Method is invoked when [Chat] instance encounters possible exception in a background process. + * Application should handle these exceptions according to the description of each [RuntimeChatException]. + * Some of these exceptions can indicate issues during transfer of messages while others may indicate that further + * interactions with [Chat] will be ignored. + */ + fun onChatRuntimeException(exception: RuntimeChatException) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandler.kt index 077d86d0..70ac9111 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandler.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandler.kt @@ -16,6 +16,7 @@ package com.nice.cxonechat import com.nice.cxonechat.event.thread.ChatThreadEvent +import com.nice.cxonechat.exceptions.CXOneException import com.nice.cxonechat.exceptions.MissingCustomerId import com.nice.cxonechat.thread.ChatThread @@ -38,13 +39,14 @@ interface ChatThreadEventHandler { * @param event [ChatThreadEvent] subclass which generates an event model. * @param listener nullable listener if the client wants to know when it * was sent. - * - * @throws MissingCustomerId in case of internal invalid state. + * @param errorListener An optional listener if the client wants to know about errors encountered when handling + * the event. */ - @Throws( - MissingCustomerId::class + fun trigger( + event: ChatThreadEvent, + listener: OnEventSentListener? = null, + errorListener: OnEventErrorListener? = null, ) - fun trigger(event: ChatThreadEvent, listener: OnEventSentListener? = null) /** * A listener to be notified when the triggered event is sent. @@ -56,4 +58,18 @@ interface ChatThreadEventHandler { */ fun onSent() } + + /** + * Listener which will be notified when the triggered event has failed with an error. + */ + @Public + fun interface OnEventErrorListener { + + /** + * Notifies about event the reason why the event wasn't sent successfully to the server. + * @param exception The cause of failure. Possible cause can be: + * * [MissingCustomerId] in case of internal invalid state. + */ + fun onError(exception: CXOneException) + } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandlerActions.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandlerActions.kt index a8811542..8bedde77 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandlerActions.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadEventHandlerActions.kt @@ -1,5 +1,21 @@ +/* + * Copyright (c) 2021-2023. 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 +import com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener import com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener import com.nice.cxonechat.event.thread.ArchiveThreadEvent import com.nice.cxonechat.event.thread.LoadThreadMetadataEvent @@ -22,7 +38,8 @@ object ChatThreadEventHandlerActions { @JvmStatic fun ChatThreadEventHandler.archiveThread( listener: OnEventSentListener? = null, - ) = trigger(ArchiveThreadEvent, listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(ArchiveThreadEvent, listener, errorListener) /** * @see ChatThreadEventHandler.trigger @@ -32,7 +49,8 @@ object ChatThreadEventHandlerActions { @JvmStatic fun ChatThreadEventHandler.markThreadRead( listener: OnEventSentListener? = null, - ) = trigger(MarkThreadReadEvent, listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(MarkThreadReadEvent, listener, errorListener) /** * @see ChatThreadEventHandler.trigger @@ -42,7 +60,8 @@ object ChatThreadEventHandlerActions { @JvmStatic fun ChatThreadEventHandler.typingEnd( listener: OnEventSentListener? = null, - ) = trigger(TypingEndEvent, listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(TypingEndEvent, listener, errorListener) /** * @see ChatThreadEventHandler.trigger @@ -52,7 +71,8 @@ object ChatThreadEventHandlerActions { @JvmStatic fun ChatThreadEventHandler.typingStart( listener: OnEventSentListener? = null, - ) = trigger(TypingStartEvent, listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(TypingStartEvent, listener, errorListener) /** * Send a [LoadThreadMetadataEvent] requesting additional thread information. @@ -64,5 +84,6 @@ object ChatThreadEventHandlerActions { @JvmStatic fun ChatThreadEventHandler.loadMetadata( listener: OnEventSentListener? = null, - ) = trigger(LoadThreadMetadataEvent, listener) + errorListener: OnEventErrorListener? = null, + ) = trigger(LoadThreadMetadataEvent, listener, errorListener) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadHandler.kt index 2cbe0627..da6a75d1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadHandler.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadHandler.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 import androidx.annotation.CheckResult diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadMessageHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadMessageHandler.kt index 72f5a203..e7247f6d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadMessageHandler.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadMessageHandler.kt @@ -64,13 +64,16 @@ interface ChatThreadMessageHandler { * background thread, though [listener] will always be invoked on a * foreground thread. * + * Note that messages with no test, attachments, or postback specified will + * be silently ignored. + * * If you are supplying attachments for upload, then be aware of the following. * - * If attachment misses file name, the file is named to "document" + * * If attachment misses file name, the file is named to "document" * upon being sent to the server. Please take care to provide localized * file names if you want to display them to the user. * - * The upload of files is performed at most **once** before subsequent + * * The upload of files is performed at most **once** before subsequent * processing of the message and sending it to the server. If the file * call succeeds, it's cached internally to avoid doubling uploads. * Therefore, subsequent calls (if the primary were to fail) are much @@ -79,12 +82,19 @@ interface ChatThreadMessageHandler { * upload. This cache is active as long as the [ChatBuilder] instance * remains the same. Reinitializing the [Chat] doesn't clear the cache. * - * If any upload of any attachment fails by connection error, [listener] + * * If any upload of any attachment fails by connection error, [listener] * will **not** be invoked for even processing triggers. The error is - * muted and consumed within the thread. + * passed to the [ChatStateListener.onChatRuntimeException] as + * an instance of [com.nice.cxonechat.exceptions.RuntimeChatException.AttachmentUploadError] + * with information about the failed attachment upload. * - * If any upload of any attachment fails by server error (returns but an + * * If any upload of any attachment fails by server error (returns but an * empty body), then the attachment is skipped, and execution continues. + * + * @param message Message to be sent. + * @param listener listener to be notified when the message has been sent. + * @throws InvalidParameterException if the message is empty, ie., has no attachment, + * message, or postback. */ fun send(message: OutboundMessage, listener: OnMessageTransferListener? = null) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadingImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadingImpl.kt index bb5bcb96..7339731e 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadingImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadingImpl.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 import com.nice.cxonechat.internal.ChatWithParameters @@ -5,7 +20,7 @@ import com.nice.cxonechat.internal.ChatWithParameters internal class ChatThreadingImpl( private val origin: ChatWithParameters ) : ChatWithParameters by origin { - override fun reconnect(): Cancellable = origin.entrails.threading.background { - origin.reconnect() + override fun connect(): Cancellable = origin.entrails.threading.background { + origin.connect() } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadsHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadsHandler.kt index 601c25fb..da1df29e 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadsHandler.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/ChatThreadsHandler.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 import androidx.annotation.CheckResult @@ -32,8 +47,11 @@ interface ChatThreadsHandler { val preChatSurvey: PreChatSurvey? /** - * Sends a request to refresh the thread-list. It's important that you register - * a callback with [threads], which returns the newly refreshed values. + * Sends a request to refresh the thread-list. + * + * This method will be called once per [ChatThreadsHandler] creation. Subsequently, + * the owner can call [refresh] again to update the threads list. One possible use + * might be to use it whenever a threads list is redisplayed after a long idle period. * * Client needs to register only one [OnThreadsUpdatedListener] per chat instance. * All subsequent [refresh] calls will notify listeners registered in this instance. diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/SocketFactoryConfiguration.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/SocketFactoryConfiguration.kt index 8c8a2526..72810f59 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/SocketFactoryConfiguration.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/SocketFactoryConfiguration.kt @@ -1,5 +1,21 @@ +/* + * Copyright (c) 2021-2023. 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 +import com.nice.cxonechat.core.BuildConfig import com.nice.cxonechat.enums.CXOneEnvironment import com.nice.cxonechat.state.Environment @@ -36,6 +52,7 @@ interface SocketFactoryConfiguration { * The library always supplies its own version, though you might be asked * (or willing to try) to change it in case of beta features, for example. */ + @Deprecated("This field is deprecated for public usage and will be removed from public API.") val version: String @Public @@ -48,7 +65,21 @@ interface SocketFactoryConfiguration { */ @JvmName("create") @JvmStatic - @JvmOverloads + @Suppress("DEPRECATION") + operator fun invoke( + environment: Environment, + brandId: Long, + channelId: String, + ) = invoke(environment, brandId, channelId, BuildConfig.VERSION_NAME) + + /** + * Helper method to create a new configuration. + * + * @see SocketFactoryConfiguration + */ + @JvmName("create") + @JvmStatic + @Deprecated("This method is deprecated for public usage and will be removed from public API.") operator fun invoke( environment: Environment, brandId: Long, @@ -58,6 +89,8 @@ interface SocketFactoryConfiguration { override val environment = environment override val brandId = brandId override val channelId = channelId + + @Deprecated("This field is deprecated for public usage and will be removed from public API.") override val version = version } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/api/RemoteServiceCaching.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/api/RemoteServiceCaching.kt index 0c5be1fa..5d5ff0d2 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/api/RemoteServiceCaching.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/api/RemoteServiceCaching.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.api import com.nice.cxonechat.api.model.AttachmentUploadResponse 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 94e01d89..6edb33fb 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.api.model import com.google.gson.annotations.SerializedName 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 8e731dd3..84e8b1bb 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.enums import com.google.gson.annotations.SerializedName diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/CXOneEnvironment.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/CXOneEnvironment.kt index 3cf01f7c..9ff40278 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/CXOneEnvironment.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/CXOneEnvironment.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.enums import com.nice.cxonechat.Public 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 new file mode 100644 index 00000000..fd0b0d4c --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/enums/ErrorType.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2021-2023. 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.enums + +import com.google.gson.annotations.SerializedName + +/** + * Definition of all errors reported by the server. + */ +internal enum class ErrorType(val value: String) { + + @SerializedName("ConsumerReconnectionFailed") + ConsumerReconnectionFailed("ConsumerReconnectionFailed"), + + @SerializedName("TokenRefreshingFailed") + TokenRefreshingFailed("TokenRefreshingFailed"), + + @SerializedName("SendingMessageFailed") + SendingMessageFailed("SendingMessageFailed"), + + @SerializedName("RecoveringLivechatFailed") + RecoveringLivechatFailed("RecoveringLivechatFailed"), + + @SerializedName("RecoveringThreadFailed") + RecoveringThreadFailed("RecoveringThreadFailed"), + + @SerializedName("SendingOutboundFailed") + SendingOutboundFailed("SendingOutboundFailed"), + + @SerializedName("UpdatingThreadFailed") + UpdatingThreadFailed("UpdatingThreadFailed"), + + @SerializedName("ArchivingThreadFailed") + ArchivingThreadFailed("ArchivingThreadFailed"), + + @SerializedName("SendingTranscriptFailed") + SendingTranscriptFailed("SendingTranscriptFailed"), + + @SerializedName("SendingOfflineMessageFailed") + SendingOfflineMessageFailed("SendingOfflineMessageFailed"), + + MetadataLoadFailed("MetadataLoadFailed"), +} 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 f4cf1c4b..9ddf0acb 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.enums import com.google.gson.annotations.SerializedName 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 fe049381..be468549 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 @@ -129,6 +129,9 @@ internal enum class EventType(val value: String) { @SerializedName("CaseCreated") CaseCreated("CaseCreated"), // TODO: Remove? + @SerializedName("CaseStatusChanged") + CaseStatusChanged("CaseStatusChanged"), + // Custom Fields /** An event to send to set custom field values for a contact (thread). */ 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 2f67838f..137e27fa 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.enums import com.google.gson.annotations.SerializedName 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 9acc96c2..4fc76796 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event import com.nice.cxonechat.internal.model.network.ActionAuthorizeCustomer 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 ac189b24..83d65625 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event import com.nice.cxonechat.Public 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 50d767f7..9b513d88 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event import com.nice.cxonechat.Public 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 d942a42e..5808e465 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 @@ -26,7 +26,7 @@ internal object ReconnectCustomerEvent : ChatEvent() { storage: ValueStorage, ) = ActionReconnectCustomer( connection = connection, - token = storage.authToken.let(::requireNotNull), + token = storage.authToken, visitor = storage.visitorId ) 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 b2d92c95..47a556e6 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event import com.nice.cxonechat.internal.model.CustomFieldModel 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 936f0ee5..bcb2ee7b 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/ChatThreadEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/ChatThreadEvent.kt index 272ed514..bb3a437f 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/ChatThreadEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/ChatThreadEvent.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event.thread import com.nice.cxonechat.ChatThreadEventHandler diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/MessageEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/MessageEvent.kt index 8dbe9621..b6c31e6d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/MessageEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/MessageEvent.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event.thread import com.nice.cxonechat.internal.model.AttachmentModel diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SendOutboundEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SendOutboundEvent.kt index 635d9656..c0b5f976 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SendOutboundEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SendOutboundEvent.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event.thread import com.nice.cxonechat.internal.model.network.ActionOutboundMessage @@ -8,6 +23,7 @@ import java.util.UUID internal class SendOutboundEvent( private val message: String, private val authToken: String?, + private val id: UUID = UUID.randomUUID(), ) : ChatThreadEvent() { override fun getModel( @@ -16,7 +32,7 @@ internal class SendOutboundEvent( ) = ActionOutboundMessage( connection = connection, thread = thread, - id = UUID.randomUUID(), + id = id, message = message, attachments = emptyList(), fields = emptyList(), // SendOutboundEvent can't have customer data (for now). diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SetContactCustomFieldEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SetContactCustomFieldEvent.kt index 1612aff1..166fa1cc 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SetContactCustomFieldEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/SetContactCustomFieldEvent.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event.thread import com.nice.cxonechat.internal.model.CustomFieldModel diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/UpdateThreadEvent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/UpdateThreadEvent.kt index c0f6151b..d816a663 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/UpdateThreadEvent.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/event/thread/UpdateThreadEvent.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.event.thread import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable 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 c638b889..0c3c1ef8 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 @@ -25,9 +25,6 @@ import com.nice.cxonechat.Public sealed class CXOneException : Exception { constructor(message: String?) : super(message) - @Suppress( - "UNUSED" // Reserved for future usage - ) constructor(message: String?, cause: Throwable?) : super(message, cause) @Suppress( @@ -43,13 +40,34 @@ sealed class CXOneException : Exception { } } +/** + * An action was attempted when a [ChatInstanceProvider] was in an invalid state. + * + * Some examples of invalid states might be: + * * calling [ChatInstanceProvider.connect] on an unprepared provider; + * * calling [ChatInstanceProvider.prepare] on a prepared or connected provider; + * + * Further details are available in the exception description. + */ +@Public +class InvalidStateException internal constructor(message: String) : CXOneException(message) + +/** + * An action was attempted with an invalid parameter. + * + * Some examples of invalid parameters are: + * * calling [ChatThreadMessageHandler.send] with a message with no message, attachment, or postback text. + */ +@Public +class InvalidParameterException internal constructor(message: String) : CXOneException(message) + /** * The method being called is not supported with the current channel configuration. */ @Public class UnsupportedChannelConfigException internal constructor() : CXOneException( "The method you are trying to call is not supported with your current channel configuration." + - " For example, archiving a thread is only supported on a channel configured for multiple threads." + " For example, archiving a thread is only supported on a channel configured for multiple threads." ) /** @@ -115,3 +133,57 @@ class MissingCustomerId internal constructor() : CXOneException( */ @Public class InternalError internal constructor(message: String) : CXOneException(message) + +/** + * The SDK was unable to dispatch analytics event to server, due to some kind of connectivity issue. + */ +@Public +class AnalyticsEventDispatchException(message: String, throwable: Throwable?) : CXOneException(message, throwable) + +/** + * + */ +@Public +sealed class RuntimeChatException(message: String, cause: Throwable? = null) : CXOneException(message, cause) { + + /** + * Exception reported when the SDK was unable to upload supplied attachment. + * + * @property attachmentName Name of file as it was specified in the + * [com.nice.cxonechat.message.ContentDescriptor.fileName] supplied for upload. + * @param cause In case of networking error it will be [java.io.IOException] or [java.lang.RuntimeException], in + * case of backend error it will be [InvalidStateException]. + */ + @Public + class AttachmentUploadError internal constructor(val attachmentName: String?, cause: Throwable?) : RuntimeChatException( + message = "Failure during upload of an attachment: $attachmentName", + cause = cause + ) + + /** + * Exception reported when server reports error in response to an action (e.g. sending of a message). + * + * The message will contain a simple code string which marks reported error. + * Errors: + * - SendingMessageFailed + * - RecoveringLivechatFailed + * - RecoveringThreadFailed + * - SendingOutboundFailed + * - UpdatingThreadFailed + * - ArchivingThreadFailed + * - SendingTranscriptFailed + * - SendingOfflineMessageFailed + * - MetadataLoadFailed + */ + @Public + class ServerCommunicationError internal constructor(message: String) : RuntimeChatException(message) + + /** + * SDK has received information from server that authorization of user has failed. + * SDK instance should be considered in invalid state. New instance should be created before connection attempt is + * made again. + * Integrators should also check if their authorization setup is correct. + */ + @Public + class AuthorizationError internal constructor(message: String) : RuntimeChatException(message) +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/CaseStatusChangedHandlerActions.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/CaseStatusChangedHandlerActions.kt new file mode 100644 index 00000000..3579d2ed --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/CaseStatusChangedHandlerActions.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021-2023. 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.internal.copy.ChatThreadCopyable.Companion.asCopyable +import com.nice.cxonechat.internal.model.ChatThreadMutable +import com.nice.cxonechat.internal.model.network.EventCaseStatusChanged +import com.nice.cxonechat.internal.model.network.EventCaseStatusChanged.CaseStatus.CLOSED +import com.nice.cxonechat.thread.ChatThread + +internal object CaseStatusChangedHandlerActions { + inline fun handleCaseClosed( + thread: ChatThreadMutable, + event: EventCaseStatusChanged, + crossinline onThreadUpdate: (ChatThread) -> Unit, + ) { + if (event.inThread(thread)) { + val notArchived = event.status !== CLOSED + val canAddMoreMessagesChanged = notArchived != thread.canAddMoreMessages + if (canAddMoreMessagesChanged) { + thread.update(thread.asCopyable().copy(canAddMoreMessages = notArchived)) + onThreadUpdate(thread) + } + } + } +} 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 2c91cfa9..ab4b3111 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.ChatActionHandler diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerLogging.kt index d7b57219..cb7bd3c6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatActionHandlerLogging.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.ChatActionHandler @@ -5,8 +20,8 @@ import com.nice.cxonechat.ChatActionHandler.OnPopupActionListener import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose internal class ChatActionHandlerLogging( private val origin: ChatActionHandler, @@ -14,14 +29,14 @@ internal class ChatActionHandlerLogging( ) : ChatActionHandler, LoggerScope by LoggerScope(logger) { init { - finest("Initialized") + verbose("Initialized") } override fun onPopup(listener: OnPopupActionListener) = scope("onPopup") { - finest("Registered") + verbose("Registered") origin.onPopup { params, metadata -> scope("onShowPopup") { - finest("params=$params, metadata=$metadata") + verbose("params=$params, metadata=$metadata") duration { listener.onShowPopup(params, metadata) } } } 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 a44fa17c..f6f0613c 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 @@ -1,14 +1,32 @@ +/* + * Copyright (c) 2021-2023. 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.Authorization import com.nice.cxonechat.Cancellable +import com.nice.cxonechat.enums.ErrorType import com.nice.cxonechat.enums.EventType.CustomerAuthorized import com.nice.cxonechat.enums.EventType.TokenRefreshed import com.nice.cxonechat.event.AuthorizeCustomerEvent import com.nice.cxonechat.event.ReconnectCustomerEvent +import com.nice.cxonechat.exceptions.RuntimeChatException.AuthorizationError import com.nice.cxonechat.internal.copy.ConnectionCopyable.Companion.asCopyable import com.nice.cxonechat.internal.model.network.EventCustomerAuthorized import com.nice.cxonechat.internal.model.network.EventTokenRefreshed +import com.nice.cxonechat.internal.socket.ErrorCallback.Companion.addErrorCallback import com.nice.cxonechat.internal.socket.EventCallback.Companion.addCallback import java.util.UUID @@ -42,9 +60,17 @@ internal class ChatAuthorization( storage.authTokenExpDate = model.expiresAt } + 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.")) + } + init { if (storage.customerId == null) { - connection = connection.asCopyable().copy(customerId = UUID.randomUUID()) + connection = connection.asCopyable().copy(customerId = UUID.randomUUID().toString()) } authorizeCustomer() } @@ -60,10 +86,12 @@ internal class ChatAuthorization( override fun close() { customerAuthorized.cancel() tokenRefresh.cancel() + customerReconnectFailed.cancel() + tokenRefreshFailed.cancel() origin.close() } - override fun reconnect(): Cancellable = origin.reconnect().also { + override fun connect(): Cancellable = origin.connect().also { authorizeCustomer() } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderDefault.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderDefault.kt index 2a5e72e9..3373fbba 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderDefault.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderDefault.kt @@ -19,15 +19,19 @@ import com.nice.cxonechat.Authorization import com.nice.cxonechat.Cancellable import com.nice.cxonechat.ChatBuilder import com.nice.cxonechat.ChatBuilder.OnChatBuiltCallback +import com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback +import com.nice.cxonechat.ChatMode.MULTI_THREAD +import com.nice.cxonechat.ChatMode.SINGLE_THREAD import com.nice.cxonechat.ChatStateListener import com.nice.cxonechat.ChatThreadingImpl import com.nice.cxonechat.internal.copy.ConnectionCopyable.Companion.asCopyable +import com.nice.cxonechat.internal.model.ChannelConfiguration import com.nice.cxonechat.internal.socket.SocketFactory import com.nice.cxonechat.internal.socket.StateReportingSocketFactory +import com.nice.cxonechat.state.Connection import retrofit2.Call import retrofit2.Callback import retrofit2.Response -import java.io.IOException internal class ChatBuilderDefault( private val entrails: ChatEntrails, @@ -62,8 +66,27 @@ internal class ChatBuilderDefault( deviceToken = token } - @Throws(IllegalStateException::class, IOException::class, RuntimeException::class) - override fun build(callback: OnChatBuiltCallback): Cancellable { + @Deprecated( + message = "Please migrate to build method with OnChatBuildResultCallback", + replaceWith = ReplaceWith( + expression = "build(resultCallback = OnChatBuiltResultCallback { callback.onChatBuilt(it.getOrThrow()) })", + imports = ["com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback"] + ) + ) + override fun build(callback: OnChatBuiltCallback): Cancellable = + build(resultCallback = { chatResult -> callback.onChatBuilt(chatResult.getOrThrow()) }) + + override fun build(resultCallback: OnChatBuiltResultCallback): Cancellable { + resultCallback.onChatBuiltResult( + runCatching { + val chatParameters = prepareChatParameters() + createChatInstance(chatParameters) + } + ) + return Cancellable.noop + } + + private fun prepareChatParameters(): ChatParameters { val socketFactory = chatStateListener?.let { StateReportingSocketFactory(it, factory) } ?: factory var connection = socketFactory.getConfiguration(entrails.storage) val firstName = firstName @@ -79,24 +102,44 @@ internal class ChatBuilderDefault( check(response.isSuccessful) { "Response from the server was not successful" } val body = checkNotNull(response.body()) { "Response body was null" } val storeVisitorCallback = if (isDevelopment) StoreVisitorCallback(entrails.logger) else IgnoredCallback + return ChatParameters(connection, socketFactory, body, storeVisitorCallback) + } + + private fun createChatInstance( + chatParameters: ChatParameters, + ): ChatWithParameters { + val storeVisitorCallback: Callback = chatParameters.storeVisitorCallback var chat: ChatWithParameters chat = ChatImpl( - connection = connection, + connection = chatParameters.connection, entrails = entrails, - socketFactory = socketFactory, - configuration = body.toConfiguration(connection.channelId), - callback = storeVisitorCallback + socketFactory = chatParameters.socketFactory, + configuration = chatParameters.body.toConfiguration(chatParameters.connection.channelId), + callback = storeVisitorCallback, + chatStateListener = chatStateListener ) + chat = ChatMemoizeThreadsHandler(chat) chat = ChatAuthorization(chat, authorization) chat = ChatStoreVisitor(chat, storeVisitorCallback) chat = ChatWelcomeMessageUpdate(chat) + chat = ChatServerErrorReporting(chat) + chat = when (chat.chatMode) { + SINGLE_THREAD -> ChatSingleThread(chat) + MULTI_THREAD -> ChatMultiThread(chat) + } chat = ChatThreadingImpl(chat) - if (isDevelopment) chat = ChatLogging(chat, entrails.logger) - callback.onChatBuilt(chat) - return Cancellable.noop + if (isDevelopment) chat = ChatLogging(chat) + return chat } } +private data class ChatParameters( + val connection: Connection, + val socketFactory: SocketFactory, + val body: ChannelConfiguration, + val storeVisitorCallback: Callback, +) + private object IgnoredCallback : Callback { override fun onResponse(call: Call, response: Response) = Unit override fun onFailure(p0: Call, p1: Throwable) = Unit diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderLogging.kt index 7a64f725..317be4e5 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderLogging.kt @@ -19,11 +19,12 @@ import com.nice.cxonechat.Authorization import com.nice.cxonechat.Cancellable import com.nice.cxonechat.ChatBuilder import com.nice.cxonechat.ChatBuilder.OnChatBuiltCallback +import com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback import com.nice.cxonechat.ChatStateListener import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration +import com.nice.cxonechat.log.error import com.nice.cxonechat.log.scope -import com.nice.cxonechat.log.severe internal class ChatBuilderLogging( private val origin: ChatBuilder, @@ -36,7 +37,7 @@ internal class ChatBuilderLogging( origin.setAuthorization(authorization) } - override fun setDevelopmentMode(enabled: Boolean) = scope("setDevelopmentMode") { + override fun setDevelopmentMode(enabled: Boolean): ChatBuilder = scope("setDevelopmentMode") { this@ChatBuilderLogging.developmentMode = enabled origin.setDevelopmentMode(enabled) } @@ -53,11 +54,27 @@ internal class ChatBuilderLogging( origin.setDeviceToken(token) } + @Deprecated( + "Please migrate to build method with OnChatBuildResultCallback", + replaceWith = ReplaceWith( + "build(resultCallback = OnChatBuiltResultCallback { callback.onChatBuilt(it.getOrThrow()) })", + "com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback" + ) + ) override fun build(callback: OnChatBuiltCallback): Cancellable = scope("build") { return try { - duration { origin.build(callback) } + duration { origin.build(resultCallback = { callback.onChatBuilt(it.getOrThrow()) }) } } catch (expected: Throwable) { - if (developmentMode) severe("Failed to initialize", expected) + if (developmentMode) error("Failed to initialize", expected) + throw expected + } + } + + override fun build(resultCallback: OnChatBuiltResultCallback): Cancellable = scope("build") { + return try { + duration { origin.build(resultCallback) } + } catch (expected: Throwable) { + if (developmentMode) error("Failed to initialize", expected) throw expected } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderRepeating.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderThreading.kt similarity index 59% rename from chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderRepeating.kt rename to chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderThreading.kt index cb2e44ec..f85d0d4d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderRepeating.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatBuilderThreading.kt @@ -15,28 +15,22 @@ package com.nice.cxonechat.internal +import android.annotation.SuppressLint import com.nice.cxonechat.Authorization import com.nice.cxonechat.Cancellable import com.nice.cxonechat.Chat import com.nice.cxonechat.ChatBuilder import com.nice.cxonechat.ChatBuilder.OnChatBuiltCallback +import com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback import com.nice.cxonechat.ChatStateListener -import java.io.IOException import java.util.concurrent.CountDownLatch -import kotlin.math.pow -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import kotlin.time.DurationUnit.SECONDS -internal class ChatBuilderRepeating( +internal class ChatBuilderThreading( private val origin: ChatBuilder, private val entrails: ChatEntrails, - private val backoff: Duration = 2.seconds, ) : ChatBuilder { - init { - check(backoff.inWholeSeconds >= 1) { "Backoff can't be lower than 1 second" } - } + private var listener: ChatStateListener? = null override fun setAuthorization(authorization: Authorization) = apply { origin.setAuthorization(authorization) @@ -51,6 +45,7 @@ internal class ChatBuilderRepeating( } override fun setChatStateListener(listener: ChatStateListener): ChatBuilder = apply { + this.listener = listener origin.setChatStateListener(listener) } @@ -58,46 +53,40 @@ internal class ChatBuilderRepeating( origin.setDeviceToken(token) } - override fun build(callback: OnChatBuiltCallback): Cancellable { + @Deprecated( + "Please migrate to build method with OnChatBuildResultCallback", + replaceWith = ReplaceWith( + "build(resultCallback = OnChatBuiltResultCallback { callback.onChatBuilt(it.getOrThrow()) })", + "com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback" + ) + ) + override fun build(callback: OnChatBuiltCallback): Cancellable = + build(resultCallback = { chatResult -> callback.onChatBuilt(chatResult.getOrThrow()) }) + + override fun build( + resultCallback: OnChatBuiltResultCallback, + ): Cancellable { val threading = entrails.threading return threading.background { - val chat = awaitBuild() + val chat = buildSynchronous() threading.foreground { - callback.onChatBuilt(chat) + resultCallback.onChatBuiltResult(chat) } } } // --- - private fun awaitBuild(): Chat { - var exponent = 0 - @Suppress("UnconditionalJumpStatementInLoop") // We need to retry, since we don't have return value - while (true) { - return try { - buildSynchronous() - } catch (ignore: IllegalStateException) { - val currentBackoff = backoff.toDouble(SECONDS).pow(exponent++).seconds - Thread.sleep(currentBackoff.inWholeMilliseconds) - continue - } - } - } - - @Throws(IllegalStateException::class) - private fun buildSynchronous(): Chat { + @SuppressLint( + "CheckResult" // Result is not used intentionally as cancellation is done via interrupt. + ) + private fun buildSynchronous(): Result { val latch = CountDownLatch(1) - var chat: Chat? = null - try { - origin.build { - chat = it - latch.countDown() - } - } catch (expected: RuntimeException) { - throw IllegalStateException(expected) - } catch (e: IOException) { - throw IllegalStateException(e) - } + var chat: Result? = null + origin.build(resultCallback = { + chat = it + latch.countDown() + }) latch.await() return checkNotNull(chat) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrails.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrails.kt index 9e790c12..4079eabe 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrails.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrails.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.api.RemoteService diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrailsAndroid.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrailsAndroid.kt index 71c86a03..e9c62e8c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrailsAndroid.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEntrailsAndroid.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 android.content.Context @@ -7,7 +22,6 @@ import com.nice.cxonechat.SocketFactoryConfiguration import com.nice.cxonechat.api.RemoteService import com.nice.cxonechat.internal.socket.SocketFactory import com.nice.cxonechat.log.Logger -import com.nice.cxonechat.log.LoggerAndroid import com.nice.cxonechat.state.Environment import com.nice.cxonechat.storage.PreferencesValueStorage import com.nice.cxonechat.storage.ValueStorage @@ -20,16 +34,18 @@ internal class ChatEntrailsAndroid( factory: SocketFactory, config: SocketFactoryConfiguration, sharedClient: OkHttpClient, + override val logger: Logger, ) : ChatEntrails { - override val storage: ValueStorage = PreferencesValueStorage(context) - override val service: RemoteService = RemoteServiceBuilder() + override val storage: ValueStorage by lazy { PreferencesValueStorage(context) } + override val service: RemoteService by lazy { + RemoteServiceBuilder() .setSharedOkHttpClient(sharedClient) .setConnection(factory.getConfiguration(storage)) .build() + } override val threading: Threading = Threading(AndroidExecutor()) override val environment: Environment = config.environment - override val logger: Logger = LoggerAndroid() private class AndroidExecutor : AbstractExecutorService() { 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 d7b040e8..ad442297 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,36 +15,54 @@ 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 import com.nice.cxonechat.event.AnalyticsEvent import com.nice.cxonechat.event.ChatEvent import com.nice.cxonechat.event.LocalEvent +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 retrofit2.Call import retrofit2.Callback import retrofit2.Response +import java.text.ParseException internal class ChatEventHandlerImpl( private val chat: ChatWithParameters, ) : ChatEventHandler { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?) { + 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 - when (val model = event.getModel(chat.connection, chat.storage)) { + val model = runCatching { + event.getModel(chat.connection, chat.storage) + }.onFailure { throwable -> + when (throwable) { + is CXOneException -> errorListener?.onError(throwable) + is ParseException, is JsonParseException -> errorListener?.onError(InternalError("Serialization error")) + } + }.getOrNull() ?: return + when (model) { is LocalEvent -> Unit - is AnalyticsEvent -> postAnalyticsEvent(model, listener) + is AnalyticsEvent -> postAnalyticsEvent(model, listener, errorListener) else -> postWSSEvent(model, listener) } } private fun postWSSEvent(model: Any, listener: OnEventSentListener?) { - chat.socket.send(model, listener?.run { ::onSent }) + chat.socket?.send(model, listener?.run { ::onSent }) } - private fun postAnalyticsEvent(event: AnalyticsEvent, listener: OnEventSentListener?) { + private fun postAnalyticsEvent( + event: AnalyticsEvent, + listener: OnEventSentListener?, + errorListener: OnEventErrorListener?, + ) { chat.service.postEvent( chat.connection.brandId.toString(), chat.storage.visitorId.toString(), @@ -56,6 +74,7 @@ internal class ChatEventHandlerImpl( override fun onFailure(call: Call, t: Throwable) { listener?.onSent() + errorListener?.onError(AnalyticsEventDispatchException(t.message ?: "Failed to dispatch event.", t)) } }) } 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 9ed40393..0419d99c 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 @@ -1,27 +1,57 @@ +/* + * Copyright (c) 2021-2023. 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.ChatEvent import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose +import com.nice.cxonechat.log.warning internal class ChatEventHandlerLogging( private val origin: ChatEventHandler, private val logger: Logger, -) : ChatEventHandler, LoggerScope by LoggerScope(logger) { +) : ChatEventHandler, LoggerScope by LoggerScope(logger) { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?) = scope("trigger") { - finest("Dispatching (event=$event)") - origin.trigger(event) { - scope("onSent") { - duration { - listener?.onSent() + override fun trigger( + event: ChatEvent, + listener: OnEventSentListener?, + errorListener: OnEventErrorListener?, + ) = scope("trigger") { + verbose("Dispatching (event=$event)") + duration { + origin.trigger( + event = event, + listener = { + scope("onSent") { + listener?.onSent() + } + }, + errorListener = { exception -> + scope("onError") { + warning("Failed to dispatch (event=$event)", exception) + errorListener?.onError(exception) + } } - } + ) } } } 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 new file mode 100644 index 00000000..b1413e7b --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatEventHandlerThreading.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021-2023. 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.ChatEvent +import com.nice.cxonechat.exceptions.CXOneException + +internal class ChatEventHandlerThreading( + private val origin: ChatEventHandler, + private val chat: ChatWithParameters, +) : ChatEventHandler { + override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + chat.entrails.threading.background { + try { + origin.trigger(event, listener, errorListener) + } catch (exception: CXOneException) { + errorListener?.onError(exception) + } + } + } +} 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 d7007c6b..0a415a93 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 @@ -16,6 +16,7 @@ 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.ChatEvent import com.nice.cxonechat.event.PageViewEndedEvent @@ -27,15 +28,15 @@ internal class ChatEventHandlerTimeOnPage( private val origin: ChatEventHandler, private val chat: ChatWithParameters, ) : ChatWithParameters by chat, ChatEventHandler { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?) { + override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { when (event) { - is PageViewEvent -> onPageViewed(event, listener) - is PageViewEndedEvent -> onPageEnded(event, listener) - else -> origin.trigger(event, listener) + is PageViewEvent -> onPageViewed(event, listener, errorListener) + is PageViewEndedEvent -> onPageEnded(event, listener, errorListener) + else -> origin.trigger(event, listener, errorListener) } } - private fun onPageViewed(event: PageViewEvent, listener: OnEventSentListener?) { + private fun onPageViewed(event: PageViewEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { // Ignore a duplicate event if (event.uri == lastPageViewed?.uri && event.title == lastPageViewed?.title) { listener?.onSent() @@ -43,14 +44,14 @@ internal class ChatEventHandlerTimeOnPage( } lastPageViewed?.let { last -> - onPageEnded(PageViewEndedEvent(last.title, last.uri, event.date), listener) + onPageEnded(PageViewEndedEvent(last.title, last.uri, event.date), listener, errorListener) } lastPageViewed = event - origin.trigger(event, listener) + origin.trigger(event, listener, errorListener) } - private fun onPageEnded(event: PageViewEndedEvent, listener: OnEventSentListener?) { + private fun onPageEnded(event: PageViewEndedEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { val last = lastPageViewed if (last != null && @@ -58,13 +59,14 @@ internal class ChatEventHandlerTimeOnPage( last.title == event.title ) { origin.trigger( - TimeSpentOnPageEvent( + event = TimeSpentOnPageEvent( uri = event.uri, title = event.title, timeSpentOnPage = max(1, (event.date.time - last.date.time) / 1000), date = event.date ), - listener + listener = listener, + errorListener = errorListener, ) } else { listener?.onSent() 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 41a57bfa..de485fbc 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 @@ -1,6 +1,22 @@ +/* + * Copyright (c) 2021-2023. 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.ChatEvent import com.nice.cxonechat.event.RefreshToken @@ -13,11 +29,11 @@ internal class ChatEventHandlerTokenGuard( private val chat: ChatWithParameters, ) : ChatEventHandler by origin { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?) { + 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) } - origin.trigger(event, listener) + origin.trigger(event, listener, errorListener) } } 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 a86b81e1..a792dcc0 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 @@ -16,6 +16,7 @@ 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.ChatEvent import com.nice.cxonechat.event.PageViewEvent @@ -28,11 +29,11 @@ internal class ChatEventHandlerVisitGuard( private val origin: ChatEventHandler, private val chat: ChatWithParameters, ) : ChatEventHandler by origin { - override fun trigger(event: ChatEvent, listener: OnEventSentListener?) { + override fun trigger(event: ChatEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { if (event is PageViewEvent) { validateVisit(event.date) } - origin.trigger(event, listener) + origin.trigger(event, listener, errorListener) } private fun validateVisit(date: Date) { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerGlobal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerGlobal.kt index c7add50f..c271803c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerGlobal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerGlobal.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.ChatFieldHandler diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerLogging.kt index 6b4dfadf..5e445c6e 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerLogging.kt @@ -1,11 +1,26 @@ +/* + * Copyright (c) 2021-2023. 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.ChatFieldHandler import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose internal class ChatFieldHandlerLogging( private val origin: ChatFieldHandler, @@ -13,7 +28,7 @@ internal class ChatFieldHandlerLogging( ) : ChatFieldHandler, LoggerScope by LoggerScope(logger) { override fun add(fields: Map): Unit = scope("add") { - finest("fields=$fields") + verbose("fields=$fields") duration { origin.add(fields) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerThread.kt index 185576a5..c19894c4 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerThread.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatFieldHandlerThread.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Chat @@ -18,10 +33,14 @@ internal class ChatFieldHandlerThread( override fun add(fields: Map) { chat.configuration.allCustomFields.validate(fields) val customFields = fields.map(::CustomFieldModel) - handler.events().trigger(SetContactCustomFieldEvent(customFields)) { - val mappedFields = customFields - .map(CustomFieldModel::toCustomField) - thread += thread.asCopyable().copy(fields = mappedFields) - } + handler.events().trigger( + event = SetContactCustomFieldEvent(customFields), + listener = { + val mappedFields = customFields + .map(CustomFieldModel::toCustomField) + thread += thread.asCopyable().copy(fields = mappedFields) + }, + errorListener = null, + ) } } 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 47f06019..34f135f6 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 @@ -19,17 +19,21 @@ import com.nice.cxonechat.Cancellable import com.nice.cxonechat.ChatActionHandler import com.nice.cxonechat.ChatEventHandler import com.nice.cxonechat.ChatFieldHandler +import com.nice.cxonechat.ChatStateListener import com.nice.cxonechat.ChatThreadsHandler import com.nice.cxonechat.event.PageViewEvent +import com.nice.cxonechat.internal.copy.ConnectionCopyable.Companion.asCopyable import com.nice.cxonechat.internal.model.ConfigurationInternal import com.nice.cxonechat.internal.model.Visitor import com.nice.cxonechat.internal.socket.ProxyWebSocketListener import com.nice.cxonechat.internal.socket.SocketFactory import com.nice.cxonechat.internal.socket.WebSocketSpec +import com.nice.cxonechat.internal.socket.WebsocketLogging import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.CustomField import okhttp3.WebSocket import retrofit2.Callback +import java.util.concurrent.atomic.AtomicReference internal class ChatImpl( override var connection: Connection, @@ -37,18 +41,19 @@ internal class ChatImpl( private val socketFactory: SocketFactory, override val configuration: ConfigurationInternal, private val callback: Callback, + override val chatStateListener: ChatStateListener?, ) : ChatWithParameters { override val socketListener: ProxyWebSocketListener = socketFactory.createProxyListener() - override val socket: WebSocket - get() = socketSession + override val socket: WebSocket? + get() = socketSession.get() override var fields = listOf() override val environment get() = entrails.environment private val actions = ChatActionHandlerImpl(this) - private var socketSession: WebSocket = socketFactory.create(socketListener) + private val socketSession: AtomicReference = AtomicReference(null) override var lastPageViewed: PageViewEvent? = null @@ -69,8 +74,9 @@ internal class ChatImpl( handler = ChatThreadsHandlerImpl(this, configuration.preContactSurvey) handler = ChatThreadsHandlerReplayLastEmpty(handler) handler = ChatThreadsHandlerConfigProxy(handler, this) - handler = ChatThreadsHandlerWelcome(handler, this) handler = ChatThreadsHandlerMessages(handler) + handler = ChatThreadsHandlerMetadata(handler, this) + handler = ChatThreadsHandlerMemoizeHandlers(handler) return handler } @@ -80,6 +86,7 @@ internal class ChatImpl( handler = ChatEventHandlerTokenGuard(handler, this) handler = ChatEventHandlerVisitGuard(handler, this) handler = ChatEventHandlerTimeOnPage(handler, this) + handler = ChatEventHandlerThreading(handler, this) return handler } @@ -93,11 +100,25 @@ internal class ChatImpl( } override fun close() { - socketSession.close(WebSocketSpec.CLOSE_NORMAL_CODE, null) + socketSession.getAndSet(null)?.close(WebSocketSpec.CLOSE_NORMAL_CODE, null) } - override fun reconnect(): Cancellable { - socketSession = socketFactory.create(socketListener) + @Deprecated("Deprecated in Chat", replaceWith = ReplaceWith("connect()")) + override fun reconnect() = connect() + + override fun connect(): Cancellable { + socketSession.set( + WebsocketLogging( + socket = socketFactory.create(socketListener), + logger = entrails.logger, + ) + ) return Cancellable.noop } + + override fun setUserName(firstName: String, lastName: String) { + if (!configuration.isAuthorizationEnabled) { + connection = connection.asCopyable().copy(firstName = firstName, lastName = lastName) + } + } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatLogging.kt index d3216db3..d7559aa6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatLogging.kt @@ -17,25 +17,23 @@ package com.nice.cxonechat.internal import com.nice.cxonechat.Chat import com.nice.cxonechat.internal.socket.EventLogger -import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose internal class ChatLogging( private val origin: ChatWithParameters, - logger: Logger, -) : ChatWithParameters by origin, LoggerScope by LoggerScope(logger) { +) : ChatWithParameters by origin, LoggerScope by LoggerScope(origin.entrails.logger) { init { - finest("Initialized (config=$configuration,environment=$environment)") - origin.socketListener.addListener(EventLogger(origin.entrails.logger)) + verbose("Initialized (config=$configuration,environment=$environment)") + origin.socketListener.addListener(EventLogger(identity)) } override fun setDeviceToken(token: String?) = scope("setDeviceToken") { duration { - finest("token=${token ?: "null"}") + verbose("token=${token ?: "null"}") origin.setDeviceToken(token) } } @@ -78,6 +76,12 @@ internal class ChatLogging( } } + override fun connect() = scope("connect") { + duration { + origin.connect() + } + } + override fun close() = scope("close") { duration { origin.close() diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatMemoizeThreadsHandler.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatMemoizeThreadsHandler.kt new file mode 100644 index 00000000..775bfa1c --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatMemoizeThreadsHandler.kt @@ -0,0 +1,17 @@ +package com.nice.cxonechat.internal + +import com.nice.cxonechat.ChatThreadsHandler + +/** + * Implementation of the [ChatWithParameters] which assures that only one instance of [ChatThreadsHandler] is ever + * created. + * + * It memorizes the first instance which is created and prevents further calls to the supplied + * [ChatWithParameters.threads] method. + */ +internal class ChatMemoizeThreadsHandler(private val origin: ChatWithParameters) : ChatWithParameters by origin { + + private val chatThreadsHandlerMemoized by lazy(origin::threads) + + override fun threads(): ChatThreadsHandler = chatThreadsHandlerMemoized +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatMultiThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatMultiThread.kt new file mode 100644 index 00000000..6cc3a3d1 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatMultiThread.kt @@ -0,0 +1,16 @@ +package com.nice.cxonechat.internal + +/** + * Handle Multi thread chat specific functionality. + * + * A chat in multithread mode is ready once connected. The process of fetching the thread + * list and metadata will be initiated once the client indicates an interest in threads by + * calling [com.nice.cxonechat.Chat.threads] + * + * @param origin Existing implementation of [ChatWithParameters] used for delegation. + */ +internal class ChatMultiThread(private val origin: ChatWithParameters) : ChatWithParameters by origin { + override fun connect() = origin.connect().also { + origin.chatStateListener?.onReady() + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatServerErrorReporting.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatServerErrorReporting.kt new file mode 100644 index 00000000..0e8df1a8 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatServerErrorReporting.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable +import com.nice.cxonechat.enums.ErrorType +import com.nice.cxonechat.enums.ErrorType.ArchivingThreadFailed +import com.nice.cxonechat.enums.ErrorType.RecoveringLivechatFailed +import com.nice.cxonechat.enums.ErrorType.RecoveringThreadFailed +import com.nice.cxonechat.enums.ErrorType.SendingMessageFailed +import com.nice.cxonechat.enums.ErrorType.SendingOfflineMessageFailed +import com.nice.cxonechat.enums.ErrorType.SendingOutboundFailed +import com.nice.cxonechat.enums.ErrorType.SendingTranscriptFailed +import com.nice.cxonechat.enums.ErrorType.UpdatingThreadFailed +import com.nice.cxonechat.exceptions.RuntimeChatException.ServerCommunicationError +import com.nice.cxonechat.internal.socket.ErrorCallback.Companion.addErrorCallback +import com.nice.cxonechat.internal.socket.ProxyWebSocketListener + +/** + * Class registers callbacks for error events which can't be directly associated with any action, either because of the + * nature of the error or because of the missing metadata in the error and asynchronous nature of the actions. + * + * The need for the callbacks can be in the future eliminated either by adding metadata to the error or by preventing + * parallel execution of (some) actions. + * + * @param origin Wrapped original [ChatWithParameters]. + */ +internal class ChatServerErrorReporting(private val origin: ChatWithParameters) : ChatWithParameters by origin { + + private val callbacks = Cancellable( + socketListener.addErrorCallback(SendingMessageFailed), + socketListener.addErrorCallback(RecoveringLivechatFailed), + socketListener.addErrorCallback(RecoveringThreadFailed), + socketListener.addErrorCallback(SendingOutboundFailed), + socketListener.addErrorCallback(UpdatingThreadFailed), + socketListener.addErrorCallback(ArchivingThreadFailed), + socketListener.addErrorCallback(SendingTranscriptFailed), + socketListener.addErrorCallback(SendingOfflineMessageFailed), + ) + + override fun close() { + callbacks.cancel() + origin.close() + } + + private fun ProxyWebSocketListener.addErrorCallback(type: ErrorType): Cancellable = addErrorCallback(type) { + chatStateListener?.onChatRuntimeException(ServerCommunicationError(type.value)) + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatSingleThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatSingleThread.kt new file mode 100644 index 00000000..e5ac8a00 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatSingleThread.kt @@ -0,0 +1,73 @@ +package com.nice.cxonechat.internal + +import com.nice.cxonechat.Cancellable +import com.nice.cxonechat.ChatThreadHandler +import com.nice.cxonechat.thread.ChatThreadState.Loaded +import com.nice.cxonechat.thread.ChatThreadState.Pending +import com.nice.cxonechat.thread.ChatThreadState.Ready + +/** + * This implementation of [com.nice.cxonechat.Chat] adds behavior which triggers early thread recovery if there is an + * existing thread. + * Once the [origin] [com.nice.cxonechat.Chat.connect] is finished, the mandatory [com.nice.cxonechat.Chat.threads] call + * is performed and the first existing thread is then `refreshed` once it's metadata are loaded. + * In order for these tasks to be of any use to the [com.nice.cxonechat.Chat] user, the [com.nice.cxonechat.Chat] has to + * memoize both the [com.nice.cxonechat.ChatThreadsHandler] and the [com.nice.cxonechat.ChatThreadHandler]. + * + * @param origin Existing implementation of [ChatWithParameters] used for delegation. + */ +internal class ChatSingleThread(private val origin: ChatWithParameters) : ChatWithParameters by origin { + + private var recoverCalled: Boolean = false + + override fun connect(): Cancellable { + origin.connect() + recoverCalled = false + return tryToRecoverThread() + } + + /** + * Attempts to recover the single thread if it exists and notifies [com.nice.cxonechat.ChatStateListener] once + * the task is done. + */ + private fun tryToRecoverThread() = origin.entrails.threading.background { + var threadsCancellable: Cancellable? = null + val threadsHandler = origin.threads() + threadsCancellable = threadsHandler.threads { threadList -> + val threadHandler = threadList.firstOrNull()?.let(threadsHandler::thread) + val threadState = threadHandler?.get()?.threadState + if (threadHandler != null && threadState !== Ready && threadState !== Pending) { + recoverThread(threadHandler) + } else { + // Either there is no thread or the thread is already recovered or it was user created. + origin.chatStateListener?.onReady() + } + // Cleanup after the first thread list update. + threadsCancellable?.cancel() + } + // Assuming that `threadsHandler.refresh` is called as part of the `threads` method. + } + + private fun recoverThread(threadHandler: ChatThreadHandler) { + var threadHandlerCancellable: Cancellable? = null + threadHandlerCancellable = threadHandler.get { thread -> + if (thread.threadState === Ready) { + // The thread was recovered, signal listener and cleanup. + origin.chatStateListener?.onReady() + threadHandlerCancellable?.cancel() + } else { + // The metadata were just loaded, recover the thread. + requestRecoverThread(threadHandler) + } + } + // Recover the thread if the metadata are already Loaded. + requestRecoverThread(threadHandler) + } + + private fun requestRecoverThread(threadHandler: ChatThreadHandler) { + if (!recoverCalled && threadHandler.get().threadState === Loaded) { + recoverCalled = true + threadHandler.refresh() + } + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatStoreVisitor.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatStoreVisitor.kt index 6fe7eb9e..a18cd182 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatStoreVisitor.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatStoreVisitor.kt @@ -30,10 +30,15 @@ internal class ChatStoreVisitor( callback: Callback, ) : ChatWithParameters by origin { init { - entrails.service.createOrUpdateVisitor( + val createOrUpdateVisitor = entrails.service.createOrUpdateVisitor( brandId = connection.brandId, visitorId = entrails.storage.visitorId.toString(), visitor = Visitor(connection, origin.storage.deviceToken) - ).enqueue(callback) + ) + runCatching { + callback.onResponse(createOrUpdateVisitor, createOrUpdateVisitor.execute()) + }.onFailure { + callback.onFailure(createOrUpdateVisitor, it) + } } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchival.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchival.kt index 7f6779e2..bc7151f6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchival.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchival.kt @@ -16,6 +16,7 @@ package com.nice.cxonechat.internal import com.nice.cxonechat.ChatThreadEventHandler +import com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener import com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener import com.nice.cxonechat.event.thread.ArchiveThreadEvent import com.nice.cxonechat.event.thread.ChatThreadEvent @@ -29,22 +30,28 @@ internal class ChatThreadEventHandlerArchival( private val chat: ChatWithParameters, private val thread: ChatThreadMutable ): ChatThreadEventHandler by origin { - override fun trigger(event: ChatThreadEvent, listener: OnEventSentListener?) { - origin.trigger(event) { - if(event is ArchiveThreadEvent) { - handleArchiveThread() - } - listener?.onSent() - } + override fun trigger(event: ChatThreadEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + origin.trigger( + event = event, + listener = { + if (event is ArchiveThreadEvent) { + handleArchiveThread() + } + listener?.onSent() + }, + errorListener = errorListener + ) } private fun handleArchiveThread() { + val socket = chat.socket ?: return + // mark this thread as archived thread += thread.asCopyable().copy(canAddMoreMessages = false) // send thread updated to host application chat.socketListener.onMessage( - chat.socket, + socket, serializer.toJson(EventThreadUpdated(thread)) ) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerImpl.kt index 659608ca..215994bf 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerImpl.kt @@ -1,6 +1,22 @@ +/* + * Copyright (c) 2021-2023. 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.ChatThreadEventHandler +import com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener import com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener import com.nice.cxonechat.event.thread.ChatThreadEvent import com.nice.cxonechat.internal.socket.send @@ -11,11 +27,13 @@ internal class ChatThreadEventHandlerImpl( private val thread: ChatThread, ) : ChatThreadEventHandler { - override fun trigger(event: ChatThreadEvent, listener: OnEventSentListener?) { + override fun trigger(event: ChatThreadEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + val socket = chat.socket ?: return + val model = event.getModel(thread, chat.connection) when (listener) { - null -> chat.socket.send(model) - else -> chat.socket.send(model) { listener.onSent() } + null -> socket.send(model) + else -> socket.send(model, listener::onSent) } } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerLogging.kt index cbad8103..2eac281c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerLogging.kt @@ -1,13 +1,30 @@ +/* + * Copyright (c) 2021-2023. 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.ChatThreadEventHandler +import com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener import com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener import com.nice.cxonechat.event.thread.ChatThreadEvent import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose +import com.nice.cxonechat.log.warning internal class ChatThreadEventHandlerLogging( private val origin: ChatThreadEventHandler, @@ -15,18 +32,27 @@ internal class ChatThreadEventHandlerLogging( ) : ChatThreadEventHandler, LoggerScope by LoggerScope(logger) { init { - finest("Initialized") + verbose("Initialized") } override fun trigger( event: ChatThreadEvent, listener: OnEventSentListener?, + errorListener: OnEventErrorListener?, ) = scope("trigger") { - finest("Dispatching (event=$event)") - origin.trigger(event) { - scope("onSent") { - duration { - listener?.onSent() + verbose("Dispatching (event=$event)") + duration { + origin.trigger( + event = event, + listener = { + scope("onSent") { + listener?.onSent() + } + } + ) { exception -> + scope("onError") { + warning("Failed to dispatch (event=$event)", exception) + errorListener?.onError(exception) } } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerThreading.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerThreading.kt new file mode 100644 index 00000000..711fff73 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerThreading.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021-2023. 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.ChatThreadEventHandler +import com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener +import com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener +import com.nice.cxonechat.event.thread.ChatThreadEvent +import com.nice.cxonechat.exceptions.CXOneException + +internal class ChatThreadEventHandlerThreading( + private val origin: ChatThreadEventHandler, + private val chat: ChatWithParameters, +) : ChatThreadEventHandler { + override fun trigger(event: ChatThreadEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { + chat.entrails.threading.background { + try { + origin.trigger(event, listener, errorListener) + } catch (exception: CXOneException) { + errorListener?.onError(exception) + } + } + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerTokenGuard.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerTokenGuard.kt index 3b47f86a..ce9bbd15 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerTokenGuard.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadEventHandlerTokenGuard.kt @@ -1,6 +1,22 @@ +/* + * Copyright (c) 2021-2023. 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.ChatThreadEventHandler +import com.nice.cxonechat.ChatThreadEventHandler.OnEventErrorListener import com.nice.cxonechat.ChatThreadEventHandler.OnEventSentListener import com.nice.cxonechat.event.RefreshToken import com.nice.cxonechat.event.thread.ChatThreadEvent @@ -13,11 +29,11 @@ internal class ChatThreadEventHandlerTokenGuard( private val chat: ChatWithParameters, ) : ChatThreadEventHandler by origin { - override fun trigger(event: ChatThreadEvent, listener: OnEventSentListener?) { + override fun trigger(event: ChatThreadEvent, listener: OnEventSentListener?, errorListener: OnEventErrorListener?) { val expiresAt = chat.storage.authTokenExpDate ?: Date(Long.MAX_VALUE) if (expiresAt.expiresWithin(10.seconds)) { chat.events().trigger(RefreshToken) } - origin.trigger(event, listener) + origin.trigger(event, listener, errorListener) } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentTyping.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentTyping.kt index 3dc194eb..1a1cb0a9 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentTyping.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentTyping.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable @@ -21,7 +36,7 @@ internal class ChatThreadHandlerAgentTyping( override fun get(listener: OnThreadUpdatedListener): Cancellable { var isTyping = false val cancellableStarted = chat.socketListener.addCallback(SenderTypingStarted) { event -> - if (event.inThread(get())) { + if (event.inThread(get()) && event.agent != null) { isTyping = true listener.onUpdated( updateThread( @@ -32,7 +47,7 @@ internal class ChatThreadHandlerAgentTyping( } } val cancellableEnded = chat.socketListener.addCallback(SenderTypingEnded) { event -> - if (event.inThread(get())) { + if (event.inThread(get()) && event.agent != null) { isTyping = false listener.onUpdated( updateThread( diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentUpdate.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentUpdate.kt index 115ccc2e..b2ef8067 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentUpdate.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerAgentUpdate.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable 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 0e579828..73df9d0a 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 @@ -21,7 +21,7 @@ import com.nice.cxonechat.ChatThreadEventHandler import com.nice.cxonechat.ChatThreadHandler import com.nice.cxonechat.ChatThreadHandler.OnThreadUpdatedListener import com.nice.cxonechat.ChatThreadMessageHandler -import com.nice.cxonechat.enums.EventType.ThreadArchived +import com.nice.cxonechat.enums.EventType.CaseStatusChanged import com.nice.cxonechat.enums.EventType.ThreadRecovered import com.nice.cxonechat.enums.EventType.ThreadUpdated import com.nice.cxonechat.event.thread.RecoverThreadEvent @@ -30,11 +30,14 @@ 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 import com.nice.cxonechat.message.Message import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState.Pending +import com.nice.cxonechat.thread.ChatThreadState.Ready internal class ChatThreadHandlerImpl( private val chat: ChatWithParameters, @@ -45,7 +48,7 @@ internal class ChatThreadHandlerImpl( override fun get(listener: OnThreadUpdatedListener): Cancellable { val onRecovered = chat.socketListener.addCallback(ThreadRecovered) { event -> - if(event.inThread(thread)) { + if (event.inThread(thread)) { updateFromEvent(event) listener.onUpdated(thread) } @@ -53,14 +56,16 @@ internal class ChatThreadHandlerImpl( val onUpdated = chat.socketListener.addCallback(ThreadUpdated) { listener.onUpdated(thread) } - val onArchived = chat.socketListener.addCallback(ThreadArchived) { - refresh() + val onArchived = chat.socketListener.addCallback(CaseStatusChanged) { event -> + CaseStatusChangedHandlerActions.handleCaseClosed(thread, event, listener::onUpdated) } return Cancellable(onRecovered, onUpdated, onArchived) } override fun refresh() { - events().trigger(RecoverThreadEvent) + if (thread.threadState != Pending) { + events().trigger(RecoverThreadEvent) + } } private fun updateFromEvent(event: EventThreadRecovered) { @@ -77,7 +82,8 @@ internal class ChatThreadHandlerImpl( fields = thread.fields.updateWith( // drop any fields not in the configuration event.thread.fields.filter { chat.configuration.allowsFieldId(it.id) } - ) + ), + threadState = Ready, ) chat.fields = chat.fields.updateWith( // drop any fields not in the configuration @@ -86,9 +92,12 @@ internal class ChatThreadHandlerImpl( } override fun setName(name: String) { - events().trigger(UpdateThreadEvent(name)) { - thread += thread.asCopyable().copy(threadName = name) - } + events().trigger( + event = UpdateThreadEvent(name), + listener = { + thread += thread.asCopyable().copy(threadName = name) + }, + ) } override fun messages(): ChatThreadMessageHandler { @@ -104,6 +113,7 @@ internal class ChatThreadHandlerImpl( handler = ChatThreadEventHandlerImpl(chat, thread) handler = ChatThreadEventHandlerTokenGuard(handler, chat) handler = ChatThreadEventHandlerArchival(handler, chat, thread) + handler = ChatThreadEventHandlerThreading(handler, chat) return handler } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerLogging.kt index 7258bd97..69457e63 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerLogging.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.ChatThreadHandler @@ -5,8 +20,8 @@ import com.nice.cxonechat.ChatThreadHandler.OnThreadUpdatedListener import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose internal class ChatThreadHandlerLogging( private val origin: ChatThreadHandler, @@ -20,7 +35,7 @@ internal class ChatThreadHandlerLogging( } override fun get(listener: OnThreadUpdatedListener) = scope("get") { - finest("Registered") + verbose("Registered") origin.get { duration { listener.onUpdated(it) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMessageReadByAgent.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMessageReadByAgent.kt new file mode 100644 index 00000000..c6651078 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMessageReadByAgent.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable +import com.nice.cxonechat.ChatThreadHandler +import com.nice.cxonechat.ChatThreadHandler.OnThreadUpdatedListener +import com.nice.cxonechat.enums.EventType.MessageReadChanged +import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable +import com.nice.cxonechat.internal.model.ChatThreadMutable +import com.nice.cxonechat.internal.model.network.EventMessageReadByAgent +import com.nice.cxonechat.internal.socket.EventCallback.Companion.addCallback + +/** + * This class wraps origin [ChatThreadHandler] and adds effect to it's [get] function, which + * will update the mutable [thread] and also trigger [OnThreadUpdatedListener.onUpdated] callback + * with updated thread, when [EventMessageReadByAgent] is received. + * + * [EventMessageReadByAgent] will always cause an update of a message. + */ +internal class ChatThreadHandlerMessageReadByAgent( + private val origin: ChatThreadHandler, + private val chat: ChatWithParameters, + private val thread: ChatThreadMutable, +) : ChatThreadHandler by origin { + + override fun get(listener: OnThreadUpdatedListener): Cancellable { + val messageReadByAgent = chat.socketListener.addCallback(MessageReadChanged) { event -> + val message = event.message + if (message == null || !event.inThread(thread)) return@addCallback + thread += thread.asCopyable().copy( + messages = thread.messages + .toMutableList() + .apply { + this[indexOfFirst { it.id == event.messageId }] = message + } + ) + listener.onUpdated(thread) + } + return Cancellable( + messageReadByAgent, + origin.get(listener) + ) + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMetadata.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMetadata.kt index 07fc5c11..6a544d1c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMetadata.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerMetadata.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable @@ -8,6 +23,7 @@ import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable import com.nice.cxonechat.internal.model.ChatThreadMutable import com.nice.cxonechat.internal.model.network.EventThreadMetadataLoaded import com.nice.cxonechat.internal.socket.EventCallback.Companion.addCallback +import com.nice.cxonechat.thread.ChatThreadState.Loaded internal class ChatThreadHandlerMetadata( private val origin: ChatThreadHandler, @@ -20,7 +36,8 @@ internal class ChatThreadHandlerMetadata( if (!event.inThread(thread)) return@addCallback thread += thread.asCopyable().copy( messages = thread.messages.ifEmpty { listOfNotNull(event.message) }, - threadAgent = event.agent ?: thread.threadAgent + threadAgent = event.agent ?: thread.threadAgent, + threadState = Loaded, ) listener.onUpdated(thread) } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerWelcome.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerWelcome.kt new file mode 100644 index 00000000..db056fa2 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadHandlerWelcome.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable +import com.nice.cxonechat.Cancellable.Companion.asCancellable +import com.nice.cxonechat.ChatThreadHandler +import com.nice.cxonechat.ChatThreadHandler.OnThreadUpdatedListener +import com.nice.cxonechat.ChatThreadMessageHandler +import com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener +import com.nice.cxonechat.event.thread.SendOutboundEvent +import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable +import com.nice.cxonechat.internal.model.ChatThreadMutable +import com.nice.cxonechat.internal.model.MessageDirectionModel.ToClient +import com.nice.cxonechat.internal.model.MessageModel +import com.nice.cxonechat.internal.model.MessageText +import com.nice.cxonechat.internal.model.network.MessagePolyContent.Text +import com.nice.cxonechat.internal.model.network.MessagePolyContent.Text.Payload +import com.nice.cxonechat.internal.model.network.UserStatistics +import com.nice.cxonechat.message.OutboundMessage +import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.CustomField +import java.util.Date +import java.util.UUID +import java.util.concurrent.FutureTask +import java.util.concurrent.atomic.AtomicBoolean + +internal class ChatThreadHandlerWelcome( + private val origin: ChatThreadHandler, + private val chat: ChatWithParameters, + private val mutableThread: ChatThreadMutable, +) : ChatThreadHandler by origin { + + private val sendOutboundEvent = AtomicBoolean(mutableThread.messages.isEmpty()) + + private val prepareWelcomeMessageTask: FutureTask = + FutureTask(::addMessageAndPrepareEvent) + + init { + if (sendOutboundEvent.get()) { + chat.entrails.threading.background(prepareWelcomeMessageTask) + } else { + prepareWelcomeMessageTask.cancel(true) + } + } + + override fun get(): ChatThread { + runCatching { prepareWelcomeMessageTask.get() } // Await completion + return origin.get() + } + + override fun get(listener: OnThreadUpdatedListener): Cancellable { + val notifyListenerWithWelcomeMessage = chat.entrails.threading.background( + FutureTask { + listener.onUpdated(get()) + } + ) + return Cancellable( + notifyListenerWithWelcomeMessage, + prepareWelcomeMessageTask.asCancellable(), + origin.get(listener) + ) + } + + override fun messages(): ChatThreadMessageHandler { + var handler = origin.messages() + if (sendOutboundEvent.get()) { + handler = WelcomeThreadMessageHandler(handler) + } + return handler + } + + private inner class WelcomeThreadMessageHandler( + private val originHandler: ChatThreadMessageHandler, + ) : ChatThreadMessageHandler by originHandler { + override fun send(message: OutboundMessage, listener: OnMessageTransferListener?) { + chat.entrails.threading.background { + if (sendOutboundEvent.getAndSet(false)) { + val welcomeMessageEvent = prepareWelcomeMessageTask.get() + welcomeMessageEvent?.let(events()::trigger) + } + originHandler.send(message = message, listener = listener) + } + } + } + + private fun addMessageAndPrepareEvent(): SendOutboundEvent? { + val storedMessage = chat.storage.welcomeMessage + if (storedMessage.isBlank()) { + sendOutboundEvent.set(false) + return null + } + val message = templateToFinalMessage(storedMessage) + val messageId = UUID.randomUUID() + val welcomeMessage = MessageText( + MessageModel( + idOnExternalPlatform = messageId, + threadIdOnExternalPlatform = mutableThread.id, + attachments = emptyList(), + createdAt = Date(), + direction = ToClient, + messageContent = Text(Payload(message)), + userStatistics = UserStatistics(null, null), + ) + ) + mutableThread += mutableThread.asCopyable().copy( + messages = mutableThread.messages.toMutableList().apply { add(0, welcomeMessage) } + ) + val token = chat.storage.authToken + return SendOutboundEvent(message, token, messageId) + } + + private fun templateToFinalMessage(storedMessage: String): String { + val connection = chat.connection + val parameters = mapOf( + "firstName" to connection.firstName, + "lastName" to connection.lastName, + ) + val customerFieldMap = chat.fields.toMap() + val contactFieldMap = origin.get().fields.toMap() + return VariableMessageParser.parse( + storedMessage, + parameters, + customerFieldMap, + contactFieldMap + ) + } + + private companion object { + @JvmStatic + private fun customFieldAsPair(customField: CustomField): Pair = customField.id to customField.value + + @JvmStatic + private fun List.toMap() = associate(::customFieldAsPair) + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerImpl.kt index c4862949..8e58db35 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerImpl.kt @@ -20,10 +20,15 @@ import com.nice.cxonechat.ChatThreadMessageHandler import com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener import com.nice.cxonechat.event.thread.LoadMoreMessagesEvent import com.nice.cxonechat.event.thread.MessageEvent +import com.nice.cxonechat.exceptions.InvalidParameterException +import com.nice.cxonechat.exceptions.InvalidStateException +import com.nice.cxonechat.exceptions.RuntimeChatException.AttachmentUploadError import com.nice.cxonechat.internal.model.AttachmentModel import com.nice.cxonechat.internal.model.AttachmentUploadModel import com.nice.cxonechat.internal.model.CustomFieldModel +import com.nice.cxonechat.message.ContentDescriptor import com.nice.cxonechat.message.OutboundMessage +import com.nice.cxonechat.utilities.isEmpty internal class ChatThreadMessageHandlerImpl( private val chat: ChatWithParameters, @@ -35,18 +40,67 @@ internal class ChatThreadMessageHandlerImpl( } override fun send(message: OutboundMessage, listener: OnMessageTransferListener?) { - val uploads = message.attachments.mapNotNull { attachment -> - val body = AttachmentUploadModel(attachment) - val response = chat.service.uploadFile(body, chat.connection.brandId.toString(), chat.connection.channelId).execute() - val url = response.body()?.fileUrl ?: return@mapNotNull null - AttachmentModel(url, attachment.friendlyName ?: "document", attachment.mimeType) + val uploads = message.attachments.mapNotNull(::uploadAttachment) + + // Ignore messages with no text, successful attachments, or postback. + if (uploads.isEmpty() && message.message.isBlank() && message.postback?.isBlank() != false) { + throw InvalidParameterException("attempt to send empty message") } + val fields = chat.fields.map(::CustomFieldModel) val event = MessageEvent(message.message, uploads, fields, chat.storage.authToken, message.postback) listener?.onProcessed(event.messageId) - thread.events().trigger(event) { - chat.fields = emptyList() - listener?.onSent(event.messageId) + thread.events().trigger( + event = event, + listener = { + chat.fields = emptyList() + listener?.onSent(event.messageId) + }, + ) + } + + private fun uploadAttachment(attachment: ContentDescriptor): AttachmentModel? { + val body = AttachmentUploadModel(attachment) + val attachmentName = attachment.fileName ?: "unknown" + val response = runCatching { + val connection = chat.connection + chat.service.uploadFile( + body = body, + brandId = connection.brandId.toString(), + channelId = connection.channelId + ).execute() + }.onFailure { throwable -> + chat.chatStateListener?.onChatRuntimeException( + AttachmentUploadError(attachmentName, throwable) + ) + }.getOrNull() + + return when { + response == null -> null + !response.isSuccessful -> { + val err = response.errorBody()?.string().orEmpty() + val errCode = response.code() + chat.chatStateListener?.onChatRuntimeException( + AttachmentUploadError( + attachmentName = attachmentName, + cause = InvalidStateException("Error code: $errCode, error message: $err") + ) + ) + null + } + response.body()?.fileUrl == null -> { + chat.chatStateListener?.onChatRuntimeException( + AttachmentUploadError( + attachmentName = attachmentName, + cause = InvalidStateException("Invalid response") + ) + ) + null + } + else -> { + val url = response.body()?.fileUrl ?: return null + AttachmentModel(url, attachment.friendlyName ?: "document", attachment.mimeType) + } } } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerLogging.kt index 51d80455..5eff084c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerLogging.kt @@ -20,8 +20,8 @@ import com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose import com.nice.cxonechat.message.OutboundMessage import java.util.UUID @@ -40,7 +40,7 @@ internal class ChatThreadMessageHandlerLogging( message: OutboundMessage, listener: OnMessageTransferListener?, ) = scope("send(${message.hashCode()})") { - finest("(message=${message.message},attachments=${message.attachments},postback=${message.postback})") + verbose("(message=${message.message},attachments=${message.attachments},postback=${message.postback})") @Suppress("NAME_SHADOWING") val listener = if (listener !is LoggingListener) LoggingListener(listener, this) else listener origin.send(message, listener) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerProxy.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerProxy.kt index 136e0990..41ea9666 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerProxy.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadMessageHandlerProxy.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.ChatThreadMessageHandler diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerConfigProxy.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerConfigProxy.kt index 74fda9bc..eedc54db 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerConfigProxy.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerConfigProxy.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerImpl.kt index 2c5c7669..f4085922 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerImpl.kt @@ -1,16 +1,32 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable import com.nice.cxonechat.ChatThreadHandler import com.nice.cxonechat.ChatThreadsHandler -import com.nice.cxonechat.enums.EventType.ThreadArchived +import com.nice.cxonechat.enums.EventType import com.nice.cxonechat.enums.EventType.ThreadListFetched import com.nice.cxonechat.event.FetchThreadEvent import com.nice.cxonechat.internal.model.ChatThreadInternal import com.nice.cxonechat.internal.model.ChatThreadMutable +import com.nice.cxonechat.internal.model.ChatThreadMutable.Companion.asMutable import com.nice.cxonechat.internal.model.CustomFieldInternal +import com.nice.cxonechat.internal.model.network.EventCaseStatusChanged import com.nice.cxonechat.internal.model.network.EventThreadListFetched -import com.nice.cxonechat.internal.model.network.ReceivedThreadData import com.nice.cxonechat.internal.socket.EventCallback.Companion.addCallback import com.nice.cxonechat.prechat.PreChatSurvey import com.nice.cxonechat.prechat.PreChatSurveyResponse @@ -21,6 +37,7 @@ import com.nice.cxonechat.state.FieldDefinition import com.nice.cxonechat.state.checkRequired import com.nice.cxonechat.state.validate import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState.Pending import java.util.UUID internal class ChatThreadsHandlerImpl( @@ -49,18 +66,26 @@ internal class ChatThreadsHandlerImpl( val uuid = UUID.randomUUID() val thread = ChatThreadInternal( id = uuid, - fields = combinedCustomFieldMap.map(::CustomFieldInternal) + fields = combinedCustomFieldMap.map(::CustomFieldInternal), + threadState = Pending, ) - return createHandler(thread) + return createHandler(thread, true) } override fun threads(listener: ChatThreadsHandler.OnThreadsUpdatedListener): Cancellable { + var threads: List = emptyList() val threadListFetched = chat.socketListener.addCallback(ThreadListFetched) { event -> - listener.onThreadsUpdated(event.threads.map(ReceivedThreadData::toChatThread)) + threads = event.threads.map { threadData -> threadData.toChatThread().asMutable() } + listener.onThreadsUpdated(threads) } - val threadArchived = chat.socketListener.addCallback(ThreadArchived) { - refresh() + val threadArchived = chat.socketListener.addCallback(EventType.CaseStatusChanged) { event -> + threads.asSequence() + .filter(event::inThread) + .forEach { thread -> + CaseStatusChangedHandlerActions.handleCaseClosed(thread, event) { listener.onThreadsUpdated(threads) } + } } + return Cancellable( threadListFetched, threadArchived @@ -73,6 +98,7 @@ internal class ChatThreadsHandlerImpl( private fun createHandler( thread: ChatThread, + addWelcomeHandler: Boolean = false, ): ChatThreadHandler { val mutableThread = thread as? ChatThreadMutable ?: ChatThreadMutable.from(thread) var handler: ChatThreadHandler @@ -81,6 +107,8 @@ internal class ChatThreadsHandlerImpl( handler = ChatThreadHandlerMessages(handler, chat, mutableThread) handler = ChatThreadHandlerAgentUpdate(handler, chat, mutableThread) handler = ChatThreadHandlerAgentTyping(handler, chat) + handler = ChatThreadHandlerMessageReadByAgent(handler, chat, mutableThread) + if (addWelcomeHandler) handler = ChatThreadHandlerWelcome(handler, chat, mutableThread) return handler } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLogging.kt index 60b7be09..7af2fc2d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLogging.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerLogging.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.ChatThreadsHandler @@ -5,8 +20,8 @@ import com.nice.cxonechat.ChatThreadsHandler.OnThreadsUpdatedListener import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope import com.nice.cxonechat.log.duration -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose import com.nice.cxonechat.prechat.PreChatSurvey import com.nice.cxonechat.prechat.PreChatSurveyResponse import com.nice.cxonechat.state.FieldDefinition @@ -41,7 +56,7 @@ internal class ChatThreadsHandlerLogging( } override fun threads(listener: OnThreadsUpdatedListener) = scope("threads") { - finest("Registered") + verbose("Registered") origin.threads { scope("onThreadsUpdated") { duration { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMemoizeHandlers.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMemoizeHandlers.kt new file mode 100644 index 00000000..286e3fa5 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMemoizeHandlers.kt @@ -0,0 +1,32 @@ +package com.nice.cxonechat.internal + +import com.nice.cxonechat.ChatThreadHandler +import com.nice.cxonechat.ChatThreadsHandler +import com.nice.cxonechat.prechat.PreChatSurveyResponse +import com.nice.cxonechat.state.FieldDefinition +import com.nice.cxonechat.thread.ChatThread +import java.util.Collections +import java.util.UUID + +/** + * Implementation of [ChatThreadsHandler] which prevents creation of multiple instances of [ChatThreadHandler] + * for [ChatThread] with the same [ChatThread.id]. + */ +internal class ChatThreadsHandlerMemoizeHandlers( + private val origin: ChatThreadsHandler, +) : ChatThreadsHandler by origin { + + private val threadHandlersMemoized: MutableMap by lazy { Collections.synchronizedMap(mutableMapOf()) } + + override fun create( + customFields: Map, + preChatSurveyResponse: Sequence>, + ): ChatThreadHandler = origin.create(customFields, preChatSurveyResponse).also(::memoizeThreadHandler) + + override fun thread(thread: ChatThread): ChatThreadHandler = + threadHandlersMemoized[thread.id] ?: origin.thread(thread).also(::memoizeThreadHandler) + + private fun memoizeThreadHandler(threadHandler: ChatThreadHandler) { + threadHandlersMemoized[threadHandler.get().id] = threadHandler + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMessages.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMessages.kt index b0d3b6d1..6a6c64ed 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMessages.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMessages.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMetadata.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMetadata.kt new file mode 100644 index 00000000..c9d58e10 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerMetadata.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable +import com.nice.cxonechat.ChatMode.MULTI_THREAD +import com.nice.cxonechat.ChatThreadEventHandlerActions.loadMetadata +import com.nice.cxonechat.ChatThreadHandler +import com.nice.cxonechat.ChatThreadsHandler +import com.nice.cxonechat.ChatThreadsHandler.OnThreadsUpdatedListener +import com.nice.cxonechat.enums.ErrorType.MetadataLoadFailed +import com.nice.cxonechat.exceptions.RuntimeChatException.ServerCommunicationError +import com.nice.cxonechat.internal.model.ChatThreadMutable +import com.nice.cxonechat.internal.model.ChatThreadMutable.Companion.asMutable +import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState.Loaded +import com.nice.cxonechat.thread.ChatThreadState.Ready +import java.util.UUID + +internal class ChatThreadsHandlerMetadata( + private val origin: ChatThreadsHandler, + private val chat: ChatWithParameters, +) : ChatThreadsHandler by origin { + private val metadataRequested = mutableSetOf() + + override fun refresh() { + metadataRequested.clear() + origin.refresh() + } + + override fun threads(listener: OnThreadsUpdatedListener) = origin.threads { threads -> + val threadIds = threads.map(ChatThread::id).toSet() + val mutableThreads = threads.map { it.asMutable() } + for (thread in mutableThreads) { + if (!metadataRequested.contains(thread.id)) { + val threadHandler = thread(thread) + registerForThreadUpdates(threadHandler, thread, listener, threads, threadIds) + requestMetadataForThread(threadHandler, thread) + } + } + listener.onThreadsUpdated(threads) + if (threads.isEmpty()) signalChatIsReady() + }.also { + refresh() + } + + private fun registerForThreadUpdates( + threadHandler: ChatThreadHandler, + thread: ChatThreadMutable, + listener: OnThreadsUpdatedListener, + threads: List, + threadIds: Set, + ) { + var onMetadataLoaded: Cancellable? = null + onMetadataLoaded = threadHandler.get { updatedThread -> + if (updatedThread.threadState === Loaded || updatedThread.threadState === Ready) { + if (updatedThread.threadState === Loaded) { + thread.update(updatedThread) + listener.onThreadsUpdated(threads) + if (metadataRequested.containsAll(threadIds)) signalChatIsReady() + } + onMetadataLoaded?.cancel() + } + } + } + + private fun requestMetadataForThread(threadHandler: ChatThreadHandler, thread: ChatThreadMutable) { + threadHandler.events().loadMetadata(listener = { + metadataRequested.add(thread.id) + }) { + chat.chatStateListener?.onChatRuntimeException( + ServerCommunicationError( + MetadataLoadFailed.value + ) + ) + } + } + + private fun signalChatIsReady() { + if (chat.chatMode === MULTI_THREAD) { + chat.chatStateListener?.onReady() + } + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerReplayLastEmpty.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerReplayLastEmpty.kt index 7ddd15f8..5423d3f9 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerReplayLastEmpty.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerReplayLastEmpty.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable @@ -9,7 +24,7 @@ import com.nice.cxonechat.state.FieldDefinition import com.nice.cxonechat.thread.ChatThread internal class ChatThreadsHandlerReplayLastEmpty( - private val origin: ChatThreadsHandlerImpl, + private val origin: ChatThreadsHandler, ) : ChatThreadsHandler by origin { private var latestThread: (() -> ChatThread)? = null diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerWelcome.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerWelcome.kt deleted file mode 100644 index fc28cc90..00000000 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatThreadsHandlerWelcome.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.nice.cxonechat.internal - -import com.nice.cxonechat.ChatThreadHandler -import com.nice.cxonechat.ChatThreadsHandler -import com.nice.cxonechat.event.thread.SendOutboundEvent -import com.nice.cxonechat.prechat.PreChatSurveyResponse -import com.nice.cxonechat.state.FieldDefinition -import com.nice.cxonechat.thread.CustomField - -internal class ChatThreadsHandlerWelcome( - private val origin: ChatThreadsHandler, - private val chat: ChatWithParameters, -) : ChatThreadsHandler by origin { - - override fun create( - customFields: Map, - preChatSurveyResponse: Sequence>, - ): ChatThreadHandler { - return origin.create(customFields, preChatSurveyResponse) - .also(::addWelcomeMessageToThread) - } - - private fun addWelcomeMessageToThread(handler: ChatThreadHandler) { - val storedMessage = chat.storage.welcomeMessage - if (storedMessage.isBlank()) return - - val connection = chat.connection - val parameters = mapOf( - "firstName" to connection.firstName, - "lastName" to connection.lastName, - ) - val customerFieldMap = chat.fields.toMap() - val contactFieldMap = handler.get().fields.toMap() - val message = VariableMessageParser.parse( - storedMessage, - parameters, - customerFieldMap, - contactFieldMap - ) - val token = chat.storage.authToken - handler.events().trigger(SendOutboundEvent(message, token)) - } - - private companion object { - private fun customFieldAsPair(customField: CustomField): Pair = customField.id to customField.value - private fun List.toMap() = associate(::customFieldAsPair) - } -} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWelcomeMessageUpdate.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWelcomeMessageUpdate.kt index d232f79d..9ec09743 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWelcomeMessageUpdate.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ChatWelcomeMessageUpdate.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.enums.ActionType 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 f85f1ea5..bc667ef5 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 @@ -16,6 +16,7 @@ package com.nice.cxonechat.internal import com.nice.cxonechat.Chat +import com.nice.cxonechat.ChatStateListener import com.nice.cxonechat.event.PageViewEvent import com.nice.cxonechat.internal.socket.ProxyWebSocketListener import com.nice.cxonechat.state.Connection @@ -25,7 +26,7 @@ import okhttp3.WebSocket internal interface ChatWithParameters : Chat { val entrails: ChatEntrails - val socket: WebSocket + val socket: WebSocket? val socketListener: ProxyWebSocketListener var connection: Connection override var fields: List @@ -33,6 +34,8 @@ internal interface ChatWithParameters : Chat { /** Last page view event received, if any. */ var lastPageViewed: PageViewEvent? + val chatStateListener: ChatStateListener? + val storage get() = entrails.storage val service get() = entrails.service } 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 c5a06d3f..6443fd57 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.api.RemoteService diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/StoreVisitorCallback.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/StoreVisitorCallback.kt index 044fe9da..1c7cfdbf 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/StoreVisitorCallback.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/StoreVisitorCallback.kt @@ -17,8 +17,8 @@ package com.nice.cxonechat.internal import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope -import com.nice.cxonechat.log.finest import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose import com.nice.cxonechat.log.warning import retrofit2.Call import retrofit2.Callback @@ -31,7 +31,7 @@ internal class StoreVisitorCallback( override fun onResponse(call: Call, response: Response) = scope("onResponse") { if (response.isSuccessful) { - logger.finest("StoreVisitor created") + logger.verbose("StoreVisitor created") } else { logger.warning("StoreVisitor creation failed: ${response.errorBody()?.string()}") } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/Threading.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/Threading.kt index e93ec76e..d627939b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/Threading.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/Threading.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ThreadingExecutor.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ThreadingExecutor.kt index d2b84c47..3dbc5fbf 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ThreadingExecutor.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/ThreadingExecutor.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.Cancellable diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/VariableMessageParser.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/VariableMessageParser.kt index 26c8ea44..3d9048a2 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/VariableMessageParser.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/VariableMessageParser.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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 internal object VariableMessageParser { 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 183805a3..6c484963 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.copy import com.nice.cxonechat.internal.model.AgentInternal diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ChatThreadCopyable.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ChatThreadCopyable.kt index 1eb4ad07..9abebe09 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ChatThreadCopyable.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ChatThreadCopyable.kt @@ -20,6 +20,7 @@ import com.nice.cxonechat.internal.model.ChatThreadMutable import com.nice.cxonechat.message.Message import com.nice.cxonechat.thread.Agent import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState import com.nice.cxonechat.thread.CustomField import java.util.UUID @@ -36,6 +37,7 @@ internal class ChatThreadCopyable( canAddMoreMessages: Boolean = model.canAddMoreMessages, scrollToken: String = model.scrollToken, fields: List = model.fields, + threadState: ChatThreadState = model.threadState, ) = ChatThreadInternal( id = id, threadName = threadName, @@ -43,7 +45,8 @@ internal class ChatThreadCopyable( threadAgent = threadAgent, canAddMoreMessages = canAddMoreMessages, scrollToken = scrollToken, - fields = fields + fields = fields, + threadState = threadState, ) companion object { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ConnectionCopyable.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ConnectionCopyable.kt index 0beb116d..08a83935 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ConnectionCopyable.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/copy/ConnectionCopyable.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.copy import com.nice.cxonechat.internal.model.ConnectionInternal @@ -15,7 +30,7 @@ internal class ConnectionCopyable( channelId: String = connection.channelId, firstName: String = connection.firstName, lastName: String = connection.lastName, - customerId: UUID? = connection.customerId, + customerId: String? = connection.customerId, environment: Environment = connection.environment, visitorId: UUID = connection.visitorId, ) = ConnectionInternal( diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ActionInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ActionInternal.kt index 8fc6af2b..25c22604 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ActionInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ActionInternal.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.PolyAction 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 c81933a3..29015310 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.thread.Agent 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 670d6774..996c5248 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 9dbc048d..5cad603b 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 @@ -1,23 +1,55 @@ +/* + * Copyright (c) 2021-2023. 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.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 java.io.FileNotFoundException import java.io.IOException import java.io.InputStream -internal data class AttachmentUploadModel( +@Suppress("UseDataClass") +internal class AttachmentUploadModel { @SerializedName("content") - val content: String? = null, + val content: String @SerializedName("mimeType") - val mimeType: String? = null, + val mimeType: String @SerializedName("fileName") - val fileName: String? = null, -) { + val fileName: String + + /** + * Default constructor from direct properties. + * + * @param content base-64 encoded content to send + * @param mimeType mime type of the attachment + * @param fileName "friendly" name of the attachment. If the name has no extension, + * then a default extension will be applied based on [mimeType]. + */ + constructor(content: String, mimeType: String, fileName: String) { + this.content = content + this.mimeType = mimeType + this.fileName = fileName.applyDefaultExtension(mimeType) + } + /** * Convenience constructor that gets all necessary fields from the passed * [ContentDescriptor]. @@ -29,9 +61,29 @@ internal data class AttachmentUploadModel( constructor(upload: ContentDescriptor): this( content = upload.content.read().base64, mimeType = upload.mimeType, - fileName = upload.friendlyName + fileName = upload.friendlyName ?: upload.fileName ) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AttachmentUploadModel + + if (content != other.content) return false + if (mimeType != other.mimeType) return false + return fileName == other.fileName + } + + override fun hashCode(): Int { + var result = content.hashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + fileName.hashCode() + return result + } + + override fun toString() = "AttachmentUploadModel(content='$content', mimeType='$mimeType', fileName='$fileName')" + companion object { /** * Encode the receiving [ByteArray] as a base-64 encoded string. @@ -49,6 +101,9 @@ internal data class AttachmentUploadModel( * @return data from [DataSource] as a base-64 encoded string * @throws IOException if the data source can't be read */ + @SuppressLint( + "Recycle" // FP - opened InputStream is closed via the `use` function. + ) @Throws(IOException::class) private fun DataSource.read() = when (this) { is DataSource.Uri -> 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 184459e9..09fc2ce6 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 c2acd016..29051fed 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 dbadf055..9e598fb8 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadInternal.kt index a344eb9d..5aa6434a 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadInternal.kt @@ -1,8 +1,24 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.message.Message import com.nice.cxonechat.thread.Agent import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState import com.nice.cxonechat.thread.CustomField import java.util.UUID @@ -14,6 +30,7 @@ internal data class ChatThreadInternal( override val canAddMoreMessages: Boolean = true, override val scrollToken: String = "", override val fields: List = emptyList(), + override val threadState: ChatThreadState, ) : ChatThread() { override fun toString() = buildString { @@ -31,6 +48,8 @@ internal data class ChatThreadInternal( append(scrollToken) append("', fields=") append(fields) + append("', state=") + append(threadState) append(")") } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadMutable.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadMutable.kt index 12c1edb2..59d3a2b5 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadMutable.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ChatThreadMutable.kt @@ -1,8 +1,24 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.message.Message import com.nice.cxonechat.thread.Agent import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState import com.nice.cxonechat.thread.CustomField import java.util.UUID @@ -26,6 +42,8 @@ internal class ChatThreadMutable private constructor( get() = thread.scrollToken override val fields: List get() = thread.fields + override val threadState: ChatThreadState + get() = thread.threadState fun update(thread: ChatThread) { this.thread = thread @@ -52,5 +70,7 @@ internal class ChatThreadMutable private constructor( is ChatThreadMutable -> thread else -> ChatThreadMutable(thread) } + + fun ChatThread.asMutable() = from(this) } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionExt.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionExt.kt index 012d835f..4376acc2 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionExt.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionExt.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.exceptions.MissingCustomerId diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionInternal.kt index 2db8e720..c8ea6133 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ConnectionInternal.kt @@ -24,7 +24,7 @@ internal data class ConnectionInternal( override val channelId: String, override val firstName: String, override val lastName: String, - override val customerId: UUID?, + override val customerId: String?, override val environment: Environment, override val visitorId: UUID, ) : Connection { 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 6bb4f525..c8edd83f 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ContentDescriptorInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ContentDescriptorInternal.kt index f00fa1ae..fa1572f0 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ContentDescriptorInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ContentDescriptorInternal.kt @@ -20,8 +20,8 @@ import com.nice.cxonechat.message.ContentDescriptor.DataSource internal data class ContentDescriptorInternal( override val content: DataSource, - override val mimeType: String?, - override val fileName: String?, + override val mimeType: String, + override val fileName: String, override val friendlyName: String?, ) : ContentDescriptor { override fun toString(): String = 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 cf2ecdff..62609fe6 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 39f430b5..6f397c5b 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 36c7f4cd..24159aae 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 @@ -17,11 +17,10 @@ package com.nice.cxonechat.internal.model import com.google.gson.annotations.SerializedName import com.nice.cxonechat.message.MessageAuthor -import java.util.UUID internal data class CustomerIdentityModel( @SerializedName("idOnExternalPlatform") - val idOnExternalPlatform: UUID, + val idOnExternalPlatform: String, @SerializedName("firstName") val firstName: String? = null, @@ -34,7 +33,7 @@ internal data class CustomerIdentityModel( ) { fun toMessageAuthor(): MessageAuthor = MessageAuthorInternal( - id = idOnExternalPlatform.toString(), + id = idOnExternalPlatform, firstName = firstName.orEmpty(), lastName = lastName.orEmpty(), imageUrl = imageUrl, 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 new file mode 100644 index 00000000..b2e1df1c --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/ErrorModel.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021-2023. 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.model + +import com.google.gson.annotations.SerializedName +import com.nice.cxonechat.enums.ErrorType + +/** + * Model for error event pushed from server. + * + * @property error Details about the error. + */ +internal data class ErrorModel( + @SerializedName("error") + val error: Error, +) { + /** + * Error details. + * + * @property errorCode One of predefined [ErrorType]s. + * @property transactionId Id of transaction which has triggered the error, usable for tracking down the cause in + * server logs. + */ + internal data class Error( + @SerializedName("errorCode") + val errorCode: ErrorType, + @SerializedName("transactionId") + val transactionId: String, + ) +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MediaInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MediaInternal.kt index f2f901e8..939ceb70 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MediaInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MediaInternal.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MediaModel 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 0ce86e27..3b2c9d77 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageListPicker.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageListPicker.kt index 59635e26..ed3fad24 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageListPicker.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageListPicker.kt @@ -1,6 +1,20 @@ +/* + * Copyright (c) 2021-2023. 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.model -import com.nice.cxonechat.internal.model.MessageModel.Companion.author import com.nice.cxonechat.internal.model.network.MessagePolyContent import com.nice.cxonechat.message.Action import com.nice.cxonechat.message.Attachment @@ -28,7 +42,7 @@ internal data class MessageListPicker( get() = model.direction.toMessageDirection() override val metadata: MessageMetadata get() = model.userStatistics.toMessageMetadata() - override val author: MessageAuthor + override val author: MessageAuthor? get() = model.author override val attachments: Iterable get() = model.attachments.map(AttachmentModel::toAttachment) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageMetadataInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageMetadataInternal.kt index 11f11a55..e8d5d295 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageMetadataInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageMetadataInternal.kt @@ -16,15 +16,27 @@ package com.nice.cxonechat.internal.model import com.nice.cxonechat.message.MessageMetadata +import com.nice.cxonechat.message.MessageStatus +import com.nice.cxonechat.message.MessageStatus.READ +import com.nice.cxonechat.message.MessageStatus.SEEN +import com.nice.cxonechat.message.MessageStatus.SENT import java.util.Date internal data class MessageMetadataInternal( + override val seenAt: Date?, override val readAt: Date?, ) : MessageMetadata { - override fun toString() = buildString { - append("MessageMetadata(readAt=") - append(readAt) - append(")") + @Transient + override val status: MessageStatus = when { + readAt != null -> READ + seenAt != null -> SEEN + else -> SENT } + + override fun toString(): String = "MessageMetadata(" + + "seenAt=$seenAt, " + + "readAt=$readAt, " + + "status=$status" + + ")" } 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 b9c95ecf..e031992e 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName @@ -11,6 +26,7 @@ import com.nice.cxonechat.internal.model.network.MessagePolyContent.QuickReplies 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 java.util.Date import java.util.UUID @@ -42,6 +58,11 @@ internal data class MessageModel( @SerializedName("authorEndUserIdentity") val authorEndUserIdentity: CustomerIdentityModel? = null, ) { + val author: MessageAuthor? + get() = when (direction) { + ToAgent -> authorEndUserIdentity?.toMessageAuthor() + ToClient -> authorUser?.toMessageAuthor() + } fun toMessage() = when (messageContent) { is Plugin -> MessagePlugin(this) @@ -51,13 +72,4 @@ internal data class MessageModel( is RichLink -> MessageRichLink(this) Noop -> null } - - companion object { - - val MessageModel.author - get() = when (direction) { - ToAgent -> authorUser?.toMessageAuthor() ?: MessageAuthorDefaults.User - ToClient -> authorEndUserIdentity?.toMessageAuthor() ?: MessageAuthorDefaults.Agent - } - } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessagePlugin.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessagePlugin.kt index 3bfd7f9c..8f56bfe6 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessagePlugin.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessagePlugin.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model -import com.nice.cxonechat.internal.model.MessageModel.Companion.author import com.nice.cxonechat.internal.model.network.MessagePolyContent import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.Message.Plugin @@ -43,7 +42,7 @@ internal data class MessagePlugin( get() = model.direction.toMessageDirection() override val metadata: MessageMetadata get() = model.userStatistics.toMessageMetadata() - override val author: MessageAuthor + override val author: MessageAuthor? get() = model.author override val attachments: Iterable get() = model.attachments.map(AttachmentModel::toAttachment) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageQuickReplies.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageQuickReplies.kt index 90fed0bd..52c4cf1e 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageQuickReplies.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageQuickReplies.kt @@ -1,6 +1,20 @@ +/* + * Copyright (c) 2021-2023. 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.model -import com.nice.cxonechat.internal.model.MessageModel.Companion.author import com.nice.cxonechat.internal.model.network.MessagePolyContent import com.nice.cxonechat.message.Action import com.nice.cxonechat.message.Attachment @@ -28,7 +42,7 @@ internal data class MessageQuickReplies( get() = model.direction.toMessageDirection() override val metadata: MessageMetadata get() = model.userStatistics.toMessageMetadata() - override val author: MessageAuthor + override val author: MessageAuthor? get() = model.author override val attachments: Iterable get() = model.attachments.map(AttachmentModel::toAttachment) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageRichLink.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageRichLink.kt index 92d018d1..073c1fcc 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageRichLink.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageRichLink.kt @@ -1,6 +1,20 @@ +/* + * Copyright (c) 2021-2023. 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.model -import com.nice.cxonechat.internal.model.MessageModel.Companion.author import com.nice.cxonechat.internal.model.network.MessagePolyContent import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.Media @@ -28,7 +42,7 @@ internal data class MessageRichLink( get() = model.direction.toMessageDirection() override val metadata: MessageMetadata get() = model.userStatistics.toMessageMetadata() - override val author: MessageAuthor + override val author: MessageAuthor? get() = model.author override val attachments: Iterable get() = model.attachments.map(AttachmentModel::toAttachment) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageText.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageText.kt index f350fd92..8fb5e7ce 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageText.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageText.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.internal.model -import com.nice.cxonechat.internal.model.MessageModel.Companion.author import com.nice.cxonechat.internal.model.network.MessagePolyContent import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.Message.Text @@ -42,7 +41,7 @@ internal data class MessageText( get() = model.direction.toMessageDirection() override val metadata: MessageMetadata get() = model.userStatistics.toMessageMetadata() - override val author: MessageAuthor + override val author: MessageAuthor? get() = model.author override val attachments: Iterable get() = model.attachments.map(AttachmentModel::toAttachment) 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 8745e6a2..9ae4b009 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementButton.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementButton.kt index cc11c36c..030d5833 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementButton.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementButton.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MessagePolyElement diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCountdown.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCountdown.kt index b015300c..72fa93d3 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCountdown.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCountdown.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MessagePolyElement diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCustom.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCustom.kt index 905198f8..6a2ad9b5 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCustom.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementCustom.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MessagePolyElement diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementFile.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementFile.kt index 3b4df280..562f8f1b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementFile.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementFile.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MessagePolyElement diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementGallery.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementGallery.kt index 19e9f289..1e7e7293 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementGallery.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementGallery.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.message.PluginElement diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementSubtitle.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementSubtitle.kt index d9853265..7a2316e1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementSubtitle.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementSubtitle.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MessagePolyElement diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementText.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementText.kt index 3cb227b4..b62c2e77 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementText.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementText.kt @@ -1,7 +1,24 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MessagePolyElement import com.nice.cxonechat.message.PluginElement.Text +import com.nice.cxonechat.message.TextFormat +import com.nice.cxonechat.message.TextFormat.Plain internal data class PluginElementText( private val element: MessagePolyElement.Text, @@ -10,19 +27,27 @@ internal data class PluginElementText( override val text: String get() = element.text + override val format = element.mimeType?.let(TextFormat::from) ?: Plain + + @Deprecated( + "isMarkdown has been deprecated, please replace with format.", + ReplaceWith("format.isMarkdown") + ) override val isMarkdown: Boolean - get() = element.mimeType.equals("text/markdown", ignoreCase = true) + get() = format.isMarkdown + @Deprecated( + "isHtml has been deprecated, please replace with format.", + ReplaceWith("format.isHtml") + ) override val isHtml: Boolean - get() = element.mimeType.equals("text/html", ignoreCase = true) + get() = format.isHtml override fun toString() = buildString { append("PluginElement.Text(text='") append(text) - append("', isMarkdown=") - append(isMarkdown) - append(", isHtml=") - append(isHtml) + append("', format=") + append(format) append(")") } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementTitle.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementTitle.kt index 04b93aec..bdec4829 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementTitle.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/PluginElementTitle.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.nice.cxonechat.internal.model.network.MessagePolyElement 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 a65b339c..26b8248e 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 a6bcdf9b..1bbf2fe8 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 6eb9b091..b4620a4b 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 3c24c939..653afe5b 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model import com.google.gson.annotations.SerializedName 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 new file mode 100644 index 00000000..9a3ef4d4 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/AccessPayload.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021-2023. 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.model.network + +import com.google.gson.annotations.SerializedName + +internal data class AccessPayload( + @SerializedName("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 f8ea83cc..c847e76f 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 53a4e306..fdcb9e01 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 f728da22..f44c9fab 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 1ba1d503..3a10a602 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 c7857b3d..68383fbf 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 c621a145..cdf62bb9 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 a7d5a358..80da0b68 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 5827bac1..872c1a1f 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 aa7bdede..77df56a2 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 1bb5c3d7..947f578f 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 14b3a948..ca948a28 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 d31f47a1..9d5811d8 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName @@ -9,9 +24,10 @@ import com.nice.cxonechat.internal.model.CustomFieldModel import com.nice.cxonechat.internal.model.Thread import com.nice.cxonechat.state.Connection import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.CustomField import java.util.UUID -internal data class ActionOutboundMessage constructor( +internal data class ActionOutboundMessage( @SerializedName("action") val action: EventAction = ChatWindowEvent, @SerializedName("eventId") @@ -73,12 +89,12 @@ internal data class ActionOutboundMessage constructor( thread = Thread(thread), messageContent = MessageContent(message), id = id, - customer = fields.takeUnless { it.isEmpty() }?.let(::CustomFieldsData), - customerContact = thread.fields.takeUnless { it.isEmpty() } + customer = fields.takeUnless(List::isEmpty)?.let(::CustomFieldsData), + customerContact = thread.fields.takeUnless(List::isEmpty) ?.map(::CustomFieldModel) ?.let(::CustomFieldsData), attachments = attachments.toList(), - accessToken = token?.takeUnless { it.isBlank() }?.let(::AccessTokenPayload) + accessToken = token?.takeUnless(String::isBlank)?.let(::AccessTokenPayload) ) } } 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 41da355d..bbda990c 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 @@ -1,9 +1,23 @@ +/* + * Copyright (c) 2021-2023. 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.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.internal.model.network.ActionRefreshToken.Data import com.nice.cxonechat.state.Connection import java.util.UUID @@ -13,18 +27,18 @@ internal data class ActionReconnectCustomer( @SerializedName("eventId") val eventId: UUID = UUID.randomUUID(), @SerializedName("payload") - val payload: LegacyPayload, + val payload: LegacyPayload, ) { constructor( connection: Connection, visitor: UUID, - token: String, + token: String?, ) : this( payload = LegacyPayload( eventType = ReconnectCustomer, connection = connection, - data = Data(token), + data = AccessPayload(token), visitor = visitor ) ) 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 5d1d04f6..5b642c99 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 089e1a21..9cf54de5 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName @@ -13,7 +28,7 @@ internal data class ActionRefreshToken( @SerializedName("eventId") val eventId: UUID = UUID.randomUUID(), @SerializedName("payload") - val payload: LegacyPayload, + val payload: LegacyPayload, ) { constructor( @@ -23,15 +38,7 @@ internal data class ActionRefreshToken( payload = LegacyPayload( eventType = RefreshToken, connection = connection, - data = Data(token) + data = AccessPayload(token) ) ) - - data class Data( - @SerializedName("accessToken") - val accessToken: AccessTokenPayload, - ) { - - constructor(token: String) : this(AccessTokenPayload(token)) - } } 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 1423816c..61fa5cce 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 1c3e6726..0035582a 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 ee860ed9..dc990de7 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 c38b96f3..deea8a81 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 bbfdf3af..6127a0ae 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 edf8b584..9b7938b3 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 0dbb1679..b8eb255d 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 68ccb8b5..45a56af3 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 f23f54e6..d106f9c2 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName @@ -11,6 +26,10 @@ internal data class EventAgentTyping( val data: Data, ) { + /** + * Information about agent which has triggered the event. + * Agent value is null if the event is triggered by the customer. + */ val agent get() = data.user?.toAgent() fun inThread(thread: ChatThread) = 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 new file mode 100644 index 00000000..89a74299 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/network/EventCaseStatusChanged.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2021-2023. 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.model.network + +import com.google.gson.annotations.SerializedName +import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.util.DateTime +import java.util.UUID + +internal data class EventCaseStatusChanged( + @SerializedName("eventId") + val eventId: UUID = UUID.randomUUID(), + @SerializedName("createdAt") + val createdAt: DateTime, + @SerializedName("data") + val data: Data, +) { + + val status + get() = data.case.status + + fun inThread(thread: ChatThread) = + data.case.threadIdOnExternalPlatform == thread.id.toString() + + internal data class Data( + @SerializedName("case") + val case: Case, + ) + + internal data class Case( + @SerializedName("threadIdOnExternalPlatform") + val threadIdOnExternalPlatform: String, + @SerializedName("status") + val status: CaseStatus, + @SerializedName("statusUpdatedAt") + val statusUpdatedAt: DateTime, + ) + + internal enum class CaseStatus { + @SerializedName("new") + NEW, + + @SerializedName("open") + OPEN, + + @SerializedName("pending") + PENDING, + + @SerializedName("escalated") + ESCALATED, + + @SerializedName("resolved") + RESOLVED, + + /** + * This state is terminal. + */ + @SerializedName("closed") + CLOSED, + + @SerializedName("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 c64edcce..941fd5c0 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 185fea8a..0b5847ae 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 9fefd6a2..815c8791 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 523eaed0..e92c93d6 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 @@ -1,7 +1,23 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName import com.nice.cxonechat.internal.model.MessageModel +import com.nice.cxonechat.thread.ChatThread /** * Event received when an agent has read a message. @@ -16,6 +32,16 @@ internal data class EventMessageReadByAgent( val messageId get() = data.message.idOnExternalPlatform val message get() = data.message.toMessage() + /** + * Returns `true` iff [threadId] matches the one of supplied [thread] and the [thread.messages] contain element + * with matching id. + */ + fun inThread(thread: ChatThread): Boolean = + thread.id == threadId && + thread.messages.any { threadMessage -> + threadMessage.id == messageId + } + data class Data( @SerializedName("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 0e708f85..cca14af6 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 468dbc7d..69d9086f 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 ce5b5c99..f3fbf182 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 6ed00969..e5fce17d 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 0154d392..5d0b31a3 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 ecc01a4d..7aaeb308 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 2918be33..725822b3 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 ac095765..a1763ae2 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 6cf8ca82..eed445fd 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 c11f9c59..b761eaeb 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 8dc17783..ec145821 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 49b2661d..ad814cc2 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 855a3986..946e9d94 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 4755a89f..f89dfdb4 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 f9897e40..ff560a85 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 @@ -1,8 +1,24 @@ +/* + * Copyright (c) 2021-2023. 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.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 java.util.Date import java.util.UUID @@ -29,6 +45,7 @@ internal data class ReceivedThreadData( id = idOnExternalPlatform, messages = mutableListOf(), canAddMoreMessages = canAddMoreMessages, - threadName = threadName + threadName = threadName, + threadState = Received, ) } 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 37c0bbdd..ffe9491a 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 a0b53c80..250d5e48 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 2f4d545a..a1ce9135 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 4ee78e41..19d4ab4e 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName @@ -14,6 +29,7 @@ internal data class UserStatistics( ) { fun toMessageMetadata(): MessageMetadata = MessageMetadataInternal( + seenAt = seenAt, readAt = readAt ) } 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 d56a91bf..ffdf2729 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 056d380b..41bbf06b 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.model.network import com.google.gson.annotations.SerializedName 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 f2f2ec2d..298eb640 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.serializer import com.google.gson.Gson @@ -13,7 +28,6 @@ import com.nice.cxonechat.internal.model.network.MessagePolyContent import com.nice.cxonechat.internal.model.network.MessagePolyElement import com.nice.cxonechat.internal.model.network.PolyAction import com.nice.cxonechat.util.DateTime -import com.nice.cxonechat.util.RuntimeTypeAdapterFactory import com.nice.cxonechat.util.timestampToDate import com.nice.cxonechat.util.toTimestamp import java.io.IOException diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/RuntimeTypeAdapterFactory.java b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/RuntimeTypeAdapterFactory.java similarity index 99% rename from chat-sdk-core/src/main/java/com/nice/cxonechat/util/RuntimeTypeAdapterFactory.java rename to chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/RuntimeTypeAdapterFactory.java index 7ec3ae6c..52f766b7 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/RuntimeTypeAdapterFactory.java +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/serializer/RuntimeTypeAdapterFactory.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.nice.cxonechat.util; +package com.nice.cxonechat.internal.serializer; import com.google.gson.Gson; import com.google.gson.JsonElement; @@ -143,7 +143,7 @@ * Shape shape = gson.fromJson(json, Shape.class); * } */ -public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { +final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { private final Class baseType; private final String typeFieldName; private final Map> labelToSubtype = new LinkedHashMap<>(); 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 new file mode 100644 index 00000000..629ee878 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ErrorCallback.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021-2023. 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.socket + +import com.nice.cxonechat.Cancellable +import com.nice.cxonechat.enums.ErrorType +import com.nice.cxonechat.internal.model.ErrorModel +import com.nice.cxonechat.internal.serializer.Default.serializer +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +/** + * Simple [WebSocketListener] which attempts to deserialize [ErrorModel] from incoming messages and if the + * [ErrorModel.Error.errorCode] matches the supplied [errorType], the element will be passed back as [onError] callback. + * + * @param errorType The [ErrorType] which will be used for callback. + */ +internal abstract class ErrorCallback( + private val errorType: ErrorType, +) : WebSocketListener() { + + override fun onMessage(webSocket: WebSocket, text: String) { + val errorMessage: ErrorModel? = serializer.runCatching { fromJson(text, ErrorModel::class.java) }.getOrNull() + if (errorMessage?.error?.errorCode == errorType) { + onError(webSocket) + } + } + + /** + * Callback which is invoked when websocket receives message with [ErrorModel] which have matching + * [ErrorModel.Error.errorCode] to the supplied [errorType]. + * + * @param webSocket [WebSocket] instance received in the [WebSocketListener.onMessage] callback. + */ + abstract fun onError(webSocket: WebSocket) + + internal companion object { + + /** + * Create and register [ErrorCallback] for supplied [errorType]. + * + * @receiver [ProxyWebSocketListener] which will be used for registration of the [ErrorCallback] listener. + * @return A [Cancellable] instance which will cancel the [ErrorCallback] registration. + */ + internal inline fun ProxyWebSocketListener.addErrorCallback( + errorType: ErrorType, + crossinline callback: WebSocket.() -> Unit, + ): Cancellable { + val listener = object : ErrorCallback(errorType) { + override fun onError(webSocket: WebSocket) = callback(webSocket) + } + addListener(listener) + return Cancellable { removeListener(listener) } + } + } +} 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 b364f9fa..6eee8a55 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.socket import com.google.gson.annotations.SerializedName 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 c408925c..8a6c9824 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.socket import com.nice.cxonechat.Cancellable @@ -12,8 +27,10 @@ internal abstract class EventCallback( ) : WebSocketListener() { override fun onMessage(webSocket: WebSocket, text: String) { - val blueprint: EventBlueprint? = serializer.fromJson(text, EventBlueprint::class.java) - if (blueprint?.anyType == type) { + val blueprint: EventBlueprint? = serializer.runCatching { + fromJson(text, EventBlueprint::class.java) + }.getOrNull() + if (blueprint?.anyType === type) { val event: Event? = serializer.fromJson(text, eventType) if (event != null) { onEvent(webSocket, event) @@ -23,15 +40,13 @@ internal abstract class EventCallback( abstract fun onEvent(websocket: WebSocket, event: Event) - companion object { + internal companion object { inline operator fun invoke( type: EventType, crossinline callback: WebSocket.(Event) -> Unit, ) = object : EventCallback(type, Event::class.java) { - override fun onEvent(websocket: WebSocket, event: Event) { - callback(websocket, event) - } + override fun onEvent(websocket: WebSocket, event: Event) = callback(websocket, event) } inline fun ProxyWebSocketListener.addCallback( 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 c03e16f4..265b7b84 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 @@ -1,10 +1,25 @@ +/* + * Copyright (c) 2021-2023. 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.socket import com.nice.cxonechat.log.Logger import com.nice.cxonechat.log.LoggerScope -import com.nice.cxonechat.log.finer -import com.nice.cxonechat.log.finest +import com.nice.cxonechat.log.debug import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.verbose import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener @@ -14,17 +29,17 @@ internal class EventLogger( ) : WebSocketListener(), LoggerScope by LoggerScope(logger) { init { - finest("Registered dispatch listener") + verbose("Registered dispatch listener") } override fun onMessage( webSocket: WebSocket, text: String, ) = scope("onMessage") { - finest(text) + verbose(text) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = scope("onFailure") { - finer("Response: $response", t) + debug("Response: $response", t) } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ProxyWebSocketListener.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ProxyWebSocketListener.kt index ed0af332..42c75a73 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ProxyWebSocketListener.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/ProxyWebSocketListener.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.socket import okhttp3.Response diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactory.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactory.kt index 8e71fd80..81cc0e95 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactory.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactory.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.socket import androidx.annotation.WorkerThread diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactoryDefault.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactoryDefault.kt index 69920db7..60f1ff13 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactoryDefault.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/SocketFactoryDefault.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.socket import com.nice.cxonechat.SocketFactoryConfiguration @@ -25,6 +40,7 @@ internal class SocketFactoryDefault( append("&channelId=${config.channelId}") append("&applicationType=native") append("&os=Android") + @Suppress("DEPRECATION") append("&clientVersion=${config.version}") } } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/StateReportingSocketFactory.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/StateReportingSocketFactory.kt index 3f737164..830fb7ab 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/StateReportingSocketFactory.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/StateReportingSocketFactory.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.socket import com.nice.cxonechat.ChatStateListener diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketSpec.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketSpec.kt index 6950dab6..3ba77520 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketSpec.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebSocketSpec.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.socket /** diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebsocketLogging.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebsocketLogging.kt new file mode 100644 index 00000000..45c3bf66 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/socket/WebsocketLogging.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021-2023. 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.socket + +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 okhttp3.WebSocket + +/** + * [LoggerScope] for [WebSocket], which logs text of messages sent via [send]. + * Also logs calls to [close]. + * + * @param socket The wrapped [WebSocket]. + * @param logger Base for the [LoggerScope] used by this implementation. + */ +internal class WebsocketLogging( + private val socket: WebSocket, + logger: Logger, +) : WebSocket by socket, LoggerScope by LoggerScope(logger) { + override fun send(text: String): Boolean = scope("send") { + verbose(text) + socket.send(text) + } + + override fun close(code: Int, reason: String?): Boolean = scope("close") { + socket.close(code, reason) + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/Level.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/log/Level.kt deleted file mode 100644 index 919646e0..00000000 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/Level.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.nice.cxonechat.log - -/** - * Logging level. - */ -sealed class Level { - - /** Integer representation of a given level. */ - abstract val intValue: Int - - /** Compares itself to [other] level for convenience. */ - operator fun compareTo(other: Level): Int = intValue.compareTo(other.intValue) - - /** Severe level corresponds to integer value of 1000. */ - object Severe : Level() { - override val intValue: Int - get() = 1000 - } - - /** Warning level corresponds to integer value of 900. */ - object Warning : Level() { - override val intValue: Int - get() = 900 - } - - /** Info level corresponds to integer value of 800. */ - object Info : Level() { - override val intValue: Int - get() = 800 - } - - /** Config level corresponds to integer value of 700. */ - object Config : Level() { - override val intValue: Int - get() = 700 - } - - /** Fine level corresponds to integer value of 500. */ - object Fine : Level() { - override val intValue: Int - get() = 500 - } - - /** Finer level corresponds to integer value of 400. */ - object Finer : Level() { - override val intValue: Int - get() = 400 - } - - /** Finest level corresponds to integer value of 300. */ - object Finest : Level() { - override val intValue: Int - get() = 300 - } - - /** - * All level corresponds to integer value of [Int.MIN_VALUE]. - * Note that this option may not be supported by all Logger - * implementations. - * */ - object All : Level() { - override val intValue: Int - get() = Int.MIN_VALUE - } - - /** - * Custom level allows you to specify your own values and - * store them statically. - * */ - class Custom( - override val intValue: Int, - ) : Level() -} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerExt.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerExt.kt deleted file mode 100644 index 39e1e0fe..00000000 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerExt.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.nice.cxonechat.log - -import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue - -internal fun Logger.finest(message: String, throwable: Throwable? = null) { - log(Level.Finest, message, throwable) -} - -internal fun Logger.finer(message: String, throwable: Throwable? = null) { - log(Level.Finer, message, throwable) -} - -internal fun Logger.fine(message: String, throwable: Throwable? = null) { - log(Level.Fine, message, throwable) -} - -internal fun Logger.info(message: String, throwable: Throwable? = null) { - log(Level.Info, message, throwable) -} - -internal fun Logger.warning(message: String, throwable: Throwable? = null) { - log(Level.Warning, message, throwable) -} - -internal fun Logger.severe(message: String, throwable: Throwable? = null) { - log(Level.Severe, message, throwable) -} - -@OptIn(ExperimentalTime::class) -internal inline fun Logger.duration(body: () -> T): T { - finest("Started") - val (value, duration) = measureTimedValue(body) - finest("Finished" took duration) - return value -} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerNoop.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerNoop.kt deleted file mode 100644 index 3befc98a..00000000 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerNoop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.nice.cxonechat.log - -internal object LoggerNoop : Logger { - - override fun log(level: Level, message: String, throwable: Throwable?) { - /* no-op */ - } -} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerScope.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerScope.kt deleted file mode 100644 index 50f03c50..00000000 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerScope.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.nice.cxonechat.log - -import kotlin.time.Duration -import kotlin.time.DurationUnit.MILLISECONDS - -internal interface LoggerScope : Logger { - - val scope: String - val identity: Logger - - companion object { - - operator fun invoke(name: String, identity: Logger): LoggerScope = NamedScope(scope = name, identity = identity) - - inline operator fun invoke(identity: Logger) = - LoggerScope(T::class.java.simpleName, identity) - } -} - -private class NamedScope( - override val scope: String, - override val identity: Logger, -) : LoggerScope { - - override fun log(level: Level, message: String, throwable: Throwable?) { - identity.log(level, "[$scope] $message", throwable) - } -} - -internal infix fun String.took(duration: Duration) = - "$this took ${duration.toDouble(MILLISECONDS)}ms" - -internal inline fun LoggerScope.scope(name: String, body: LoggerScope.() -> T): T = - body(LoggerScope("$scope/$name", identity)) diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Action.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Action.kt index 49939be4..e52190f2 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Action.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Action.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.message import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/ContentDescriptor.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/ContentDescriptor.kt index fcd0ad95..fa43442d 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/ContentDescriptor.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/ContentDescriptor.kt @@ -38,16 +38,14 @@ interface ContentDescriptor { * * @property bytes actual content */ - class Bytes internal constructor(val bytes: ByteArray): DataSource() { + internal class Bytes(val bytes: ByteArray) : DataSource() { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as Bytes - if (!bytes.contentEquals(other.bytes)) return false - - return true + return bytes.contentEquals(other.bytes) } override fun hashCode(): Int = bytes.contentHashCode() @@ -61,9 +59,9 @@ interface ContentDescriptor { * @property uri original uri of content * @property context Android context to be used to open the [uri] */ - class Uri internal constructor( + internal class Uri( val uri: android.net.Uri, - val context: Context + val context: Context, ): DataSource() { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -71,9 +69,7 @@ interface ContentDescriptor { other as Uri - if (uri != other.uri) return false - - return true + return uri == other.uri } override fun hashCode(): Int = uri.hashCode() @@ -92,14 +88,15 @@ interface ContentDescriptor { * It's required to properly deserialize the file after upload. * * Visit [IANA](https://www.iana.org/) for valid mime types - * */ - val mimeType: String? + */ + val mimeType: String /** * Name of provided in [content]. * Should contain the file name extension corresponding to the [mimeType]. - * */ - val fileName: String? + * Note: Either [mimeType] *must* be specified or [fileName] must include a valid extension. + */ + val fileName: String /** * Friendly name provided in [content]. @@ -124,9 +121,9 @@ interface ContentDescriptor { operator fun invoke( content: android.net.Uri, context: Context, - mimeType: String?, - fileName: String?, - friendlyName: String? + mimeType: String, + fileName: String, + friendlyName: String? = null ): ContentDescriptor = ContentDescriptorInternal( content = DataSource.Uri(content, context), mimeType = mimeType, @@ -146,8 +143,8 @@ interface ContentDescriptor { @JvmName("create") operator fun invoke( content: ByteArray, - mimeType: String?, - fileName: String?, + mimeType: String, + fileName: String, friendlyName: String? ): ContentDescriptor = ContentDescriptorInternal( content = DataSource.Bytes(content), diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Media.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Media.kt index 6f966335..6c6426b1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Media.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Media.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.message import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Message.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Message.kt index 37366566..dd84084a 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Message.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/Message.kt @@ -38,27 +38,27 @@ sealed class Message { /** * The id that was assigned to this message. * If another message has matching id, it's the same message with possibly different content. - * */ + */ abstract val id: UUID /** * The thread id that this message belongs to. Messages should never be * mismatched between threads as this can lead to inconsistencies and * undefined behavior. - * */ + */ abstract val threadId: UUID /** * The timestamp of when the message was created on the server. Similarly * to [id] this field shouldn't change for the lifetime of the message. - * */ + */ abstract val createdAt: Date /** * The direction in which the message is sent. * * @see MessageDirection - * */ + */ abstract val direction: MessageDirection /** @@ -66,15 +66,18 @@ sealed class Message { * contain anything from message status to custom properties. * * @see MessageMetadata - * */ + */ abstract val metadata: MessageMetadata /** * Author associated with this message. * + * *Note:* Under some circumstances the author name may be unknown and [author] will be null. + * The UI can provide a suitable localized fallback based on [direction]. + * * @see MessageAuthor - * */ - abstract val author: MessageAuthor + */ + abstract val author: MessageAuthor? /** * Attachments provided with the message. This field can be empty. @@ -84,7 +87,7 @@ sealed class Message { * Never make any assumption on the implemented type of the [Iterable]. * * @see ChatThreadMessageHandler.send - * */ + */ abstract val attachments: Iterable /** @@ -97,7 +100,7 @@ sealed class Message { /** * Simple message representing only text content. This type of content * is usually generated through User or Agent replying to each other. - * */ + */ @Public abstract class Text : Message() { @@ -109,7 +112,7 @@ sealed class Message { * characters out of scope for your current device (typically emojis * or unsupported characters from some languages). Use support * libraries to display them, if applicable. - * */ + */ abstract val text: String } @@ -120,14 +123,14 @@ sealed class Message { * required by your own specification. Note that new elements can be added * to the backend services for which you need to update the SDK. New * elements, previously undefined are ignored by the SDK until implemented. - * */ + */ @Public abstract class Plugin : Message() { /** * Additional information provided alongside with the message. Refer to * information given by a representative to correctly use this parameter. - * */ + */ abstract val postback: String? /** @@ -136,7 +139,7 @@ sealed class Message { * this version. Kindly update the SDK in order to gain support. * * @see PluginElement - * */ + */ abstract val element: PluginElement? } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageDirection.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageDirection.kt index 53d14236..4e0bb56b 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageDirection.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageDirection.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.message import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageMetadata.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageMetadata.kt index 85c148bf..d6804927 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageMetadata.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageMetadata.kt @@ -23,9 +23,19 @@ import java.util.Date * */ @Public interface MessageMetadata { + /** + * The date at which the message was seen (delivered) on backend. + * Default to null if the message is freshly sent by the SDK, always non-null + * if the message is delivered. + */ + val seenAt: Date? + /** * The date at which the message was read. * Defaults to null if the message is freshly sent or delivered. * */ val readAt: Date? + + /** Inferred status of message. */ + val status: MessageStatus } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageStatus.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageStatus.kt new file mode 100644 index 00000000..ae4252b1 --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/MessageStatus.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021-2023. 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.message + +import com.nice.cxonechat.Public + +/** + * Enumeration of possible message states, including those not reported by the SDK. + */ +@Public +enum class MessageStatus { + /** + * Status which can be used by UI implementation, for message passed to SDK, but not yet confirmed + * as [SENT]. + */ + SENDING, + + /** Default state of message when it has been processed by the SDK and sent to backend. */ + SENT, + + /** Status which can be used by UI implementation. */ + FAILED_TO_DELIVER, + + /** Status reported when the message is reported as delivered/seen on backend. */ + SEEN, + + /** Status reported when the message is reported as read. */ + READ, +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/OutboundMessage.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/OutboundMessage.kt index fb209c53..aa40edac 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/OutboundMessage.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/OutboundMessage.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.message import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/PluginElement.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/PluginElement.kt index 6f76b18e..cf13df10 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/PluginElement.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/PluginElement.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.message import com.nice.cxonechat.Public @@ -25,7 +40,7 @@ import java.util.Date * can extract properties defined by your company or representative. * * @see Message.Plugin - * */ + */ @Public sealed class PluginElement { @@ -34,7 +49,7 @@ sealed class PluginElement { * These elements are highly variable and there's no guarantee that all * of the elements will be present at any given time. Though at least one * element should be present at any given time. - * */ + */ @Public abstract class Menu : PluginElement() { /** @@ -45,7 +60,7 @@ sealed class PluginElement { * then iterable returns no elements. * * @see File - * */ + */ abstract val files: Iterable /** @@ -56,7 +71,7 @@ sealed class PluginElement { * then iterable returns no elements. * * @see Title - * */ + */ abstract val titles: Iterable /** @@ -68,7 +83,7 @@ sealed class PluginElement { * then iterable returns no elements. * * @see Subtitle - * */ + */ abstract val subtitles: Iterable<Subtitle> /** @@ -79,7 +94,7 @@ sealed class PluginElement { * then iterable returns no elements. * * @see Text - * */ + */ abstract val texts: Iterable<Text> /** @@ -91,7 +106,7 @@ sealed class PluginElement { * then iterable returns no elements. * * @see Button - * */ + */ abstract val buttons: Iterable<Button> } @@ -99,28 +114,28 @@ sealed class PluginElement { * File of any type defined by [mimeType]. This class doesn't carry any * guarantees of the file existing on the remote server, if you need to * know whether it exists use HEAD request on the url. - * */ + */ @Public abstract class File : PluginElement() { /** * Url for the file on a remote server. It can required authorization * though there's no information or guarantee that it does. Use your * representative's expertise. - * */ + */ abstract val url: String /** * Name of the original file which was used to upload to a remote * server. This should be something either human-readable or user * defined from when the user requested to upload this file. - * */ + */ abstract val name: String /** * Mime type of the file hosted on [url]. Don't assume all files are * equal in type! Make sure to correctly categorize files based on * their mime types while integrating them to your application. - * */ + */ abstract val mimeType: String } @@ -130,14 +145,14 @@ sealed class PluginElement { * * @see Menu * @see InactivityPopup - * */ + */ @Public abstract class Title : PluginElement() { /** * Text ought to be displayed in a title styleable component. It is * not localized to your app's language though. Uses language defined * in your agent console. - * */ + */ abstract val text: String } @@ -147,14 +162,14 @@ sealed class PluginElement { * * @see Menu * @see InactivityPopup - * */ + */ @Public abstract class Subtitle : PluginElement() { /** * Text ought to be displayed in a subtitle styleable component. It is * not localized to your app's language though. Uses language defined * in your agent console. - * */ + */ abstract val text: String } @@ -163,26 +178,36 @@ sealed class PluginElement { * Contextually can be of a different form, such as html or markdown. * It's up to the integrator to format or strip the text's format * if they do not wish to use formatted text. - * */ + */ @Public abstract class Text : PluginElement() { /** - * Text with formatting. This is determined by additional properties - * [isMarkdown] or [isHtml]. - * */ + * Text with formatting. The embedded formatting is determined by [format]. + */ @Suppress( "MemberNameEqualsClassName" // Part of shared API. ) abstract val text: String + /** Embedded formatting to be applied to the text. */ + abstract val format: TextFormat + /** * Determines whether is this [text] markdown formatted. - * */ + */ + @Deprecated( + "isMarkdown has been deprecated, please replace with format.", + ReplaceWith("format.isMarkdown") + ) abstract val isMarkdown: Boolean /** * Determines whether is this [text] html formatted. - * */ + */ + @Deprecated( + "isHtml has been deprecated, please replace with format.", + ReplaceWith("format.isHtml") + ) abstract val isHtml: Boolean } @@ -196,7 +221,7 @@ sealed class PluginElement { /** * Text to display in place of the button. * Text is unformatted and localized, according as per agent console settings. - * */ + */ abstract val text: String /** @@ -213,7 +238,7 @@ sealed class PluginElement { * For an example, you might want to use it to redirect the user directly * to a Facebook profile or perform another similar action. * Or it can be just a plain URL. - * */ + */ abstract val deepLink: String? /** @@ -230,14 +255,14 @@ sealed class PluginElement { * * @see Text * @see Button - * */ + */ @Public abstract class TextAndButtons : PluginElement() { /** * Text associated with a choice which is presented by [buttons]. * * @see Text - * */ + */ abstract val text: Text /** @@ -245,7 +270,7 @@ sealed class PluginElement { * display buttons without displaying text component. Buttons might * be as simple as "Yes", which needs the contextual information * from [text]. - * */ + */ abstract val buttons: Iterable<Button> } @@ -256,7 +281,7 @@ sealed class PluginElement { * * @see Text * @see Button - * */ + */ @Public abstract class QuickReplies : PluginElement() { /** @@ -264,7 +289,7 @@ sealed class PluginElement { * information and is only informative to the user. * * @see Text - * */ + */ abstract val text: Text? /** @@ -274,7 +299,7 @@ sealed class PluginElement { * undefined. * * @see Button - * */ + */ abstract val buttons: Iterable<Button> } @@ -293,7 +318,7 @@ sealed class PluginElement { * @see Text * @see Button * @see Countdown - * */ + */ @Public abstract class InactivityPopup : PluginElement() { /** @@ -301,7 +326,7 @@ sealed class PluginElement { * inactivity. * * @see Title - * */ + */ abstract val title: Title /** @@ -309,7 +334,7 @@ sealed class PluginElement { * popup. * * @see Subtitle - * */ + */ abstract val subtitle: Subtitle? /** @@ -317,7 +342,7 @@ sealed class PluginElement { * should have its own widget (or view). * * @see Text - * */ + */ abstract val texts: Iterable<Text> /** @@ -326,7 +351,7 @@ sealed class PluginElement { * sending a message to the given thread. * * @see Button - * */ + */ abstract val buttons: Iterable<Button> /** @@ -335,7 +360,7 @@ sealed class PluginElement { * if countdown is expired. * * @see Countdown - * */ + */ abstract val countdown: Countdown } @@ -343,7 +368,7 @@ sealed class PluginElement { * Countdown component. Countdowns should affect whether are * interactive components active. Interactive components might be * [Custom] or [Button]. - * */ + */ @Public abstract class Countdown : PluginElement() { /** @@ -352,13 +377,13 @@ sealed class PluginElement { * be taken before the countdown expires. * * @see isExpired - * */ + */ abstract val endsAt: Date /** * Method that checks current time against [endsAt]. It can be * polled periodically to check whether actions should be active. - * */ + */ abstract val isExpired: Boolean } @@ -367,19 +392,19 @@ sealed class PluginElement { * to the custom action is deserialized as [Map]. If the [variables] * are invalid, from integrator's point of view, then they should use * [fallbackText]. - * */ + */ @Public abstract class Custom : PluginElement() { /** * Default text that should be used only in cases where [variables] * is deemed invalid. - * */ + */ abstract val fallbackText: String? /** * Variables deserialized from objects supplied from backend. These * are never modified or injected by the SDK. - * */ + */ abstract val variables: Map<String, Any?> } diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/message/TextFormat.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/TextFormat.kt new file mode 100644 index 00000000..4237603a --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/message/TextFormat.kt @@ -0,0 +1,55 @@ +/* + * 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.message + +import com.nice.cxonechat.Public + +/** + * Inline formatting to be parsed from a text element. + * + * @property mimeType Actual mime type used. + */ +@Public +enum class TextFormat( + val mimeType: String +) { + /** Plain unformatted text. */ + Plain("text"), + + /** HTML formatted text. */ + Html("text/html"), + + /** Markdown formatted text. */ + Markdown("text/markdown"); + + /** Returns true iff the encoding is Html. */ + val isHtml: Boolean + get() = this === Html + + /** Returns true iff the encoding is Markdown. */ + val isMarkdown: Boolean + get() = this === Markdown + + internal companion object { + /** + * Create a [TextFormat] given the proper mime type. If there is no match, [Plain] is assumed. + * + * @param mimeType MimeType to decode. + * @return Type associated with [mimeType] or [Plain] if there is no match. + */ + fun from(mimeType: String) = entries.firstOrNull { it.mimeType.equals(mimeType, ignoreCase = true) } ?: Plain + } +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/prechat/PreChatSurveyResponse.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/prechat/PreChatSurveyResponse.kt index eb4cbb3a..06f947c4 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/prechat/PreChatSurveyResponse.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/prechat/PreChatSurveyResponse.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.prechat import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/Connection.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/Connection.kt index 4d43b223..0892ec59 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/Connection.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/Connection.kt @@ -51,9 +51,9 @@ interface Connection { * It's automatically generated and not empty once connected * to the supporting socket for the first time. * - * It's also unchanged as long as the app data are intact. + * It may be changed by value originating from backend, e.g. in case of OAuth Authorization. */ - val customerId: UUID? + val customerId: String? /** * The environment through which this instance connected. diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNode.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNode.kt index 6a457aae..3ba490c8 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNode.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNode.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.state import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNodeInternal.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNodeInternal.kt index 450897d8..f2a07bff 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNodeInternal.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/HierarchyNodeInternal.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.state import com.nice.cxonechat.internal.model.NodeModel diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNode.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNode.kt index 72e4b9c0..1c92be7c 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNode.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNode.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.state import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNodeImpl.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNodeImpl.kt index 26dda3a8..ab6738d8 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNodeImpl.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/state/SelectorNodeImpl.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.state internal data class SelectorNodeImpl( 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 b4898224..1e460ac1 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 @@ -43,10 +43,10 @@ internal class PreferencesValueStorage(private val sharedPreferences: SharedPref } } - override var customerId: UUID? - get() = sharedPreferences.getUUID(PREF_CUSTOMER_ID, null) + override var customerId: String? + get() = sharedPreferences.getString(PREF_CUSTOMER_ID, null) set(value) = sharedPreferences.edit { - putString(PREF_CUSTOMER_ID, value.toString()) + putString(PREF_CUSTOMER_ID, value) } override var visitorId: 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 62fc6fbc..5df10f36 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 @@ -75,7 +75,7 @@ internal interface ValueStorage { * Authorized user id. * Default value is null. */ - var customerId: UUID? + var customerId: String? /** * Connection session id. 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 cf8ae5bd..b9d97b5e 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.thread import com.nice.cxonechat.Public diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/ChatThread.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/ChatThread.kt index 17bea625..400487b1 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/ChatThread.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/ChatThread.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.thread import com.nice.cxonechat.Public @@ -27,6 +42,11 @@ abstract class ChatThread { /** The token for the scroll position used to load more messages. */ abstract val scrollToken: String + /** + * Current state of the thread. + */ + abstract val threadState: ChatThreadState + /** Custom fields attached to this thread. */ abstract val fields: List<CustomField> diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/ChatThreadState.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/ChatThreadState.kt new file mode 100644 index 00000000..1455bd8e --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/thread/ChatThreadState.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.thread + +import com.nice.cxonechat.Public + +/** + * Current state of a [ChatThread]. + */ +@Public +enum class ChatThreadState { + /** A locally created thread that has not yet been verified by the server. */ + Pending, + + /** + * A thread that has been received from the server, probably via [ThreadListRecievedEvent], + * but is not yet ready for use. + */ + Received, + + /** A thread that has been received from the server and has had its metadata loaded. */ + Loaded, + + /** + * A thread that is completely ready for use, either because it was locally created or + * because both the metadata and thread details have been recovered. + */ + Ready +} 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 f215d144..5156a783 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 @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.util import java.util.Date diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateUtil.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateUtil.kt index b8c16976..0ced61cb 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateUtil.kt +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/DateUtil.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.util import java.text.ParseException @@ -7,12 +22,14 @@ import java.util.Locale import java.util.TimeZone import kotlin.time.Duration -private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'z'", Locale.ROOT).also { - it.timeZone = TimeZone.getTimeZone("UTC") -} -private val timestampFormatWithoutMillis = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZ", Locale.ROOT).also { - it.timeZone = TimeZone.getTimeZone("UTC") -} +private val timestampFormat: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'z'", Locale.ROOT).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } +private val timestampFormatWithoutMillis: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZ", Locale.ROOT).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } @Throws(ParseException::class) internal fun String.timestampToDate(): Date { diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/util/StringExt.kt b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/StringExt.kt new file mode 100644 index 00000000..4b4cb84f --- /dev/null +++ b/chat-sdk-core/src/main/java/com/nice/cxonechat/util/StringExt.kt @@ -0,0 +1,31 @@ +/* + * 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.util + +import android.webkit.MimeTypeMap + +internal fun String.applyDefaultExtension(mimeType: String): String { + return if (contains('.')) { + this + } else { + val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) + if (ext != null) { + "$this.$ext" + } else { + this + } + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTest.kt index ad735d52..34e5111f 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTest.kt @@ -1,5 +1,25 @@ +/* + * Copyright (c) 2021-2023. 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 +import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection.CONNECTED +import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection.INITIAL +import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection.READY +import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection.UNEXPECTED_DISCONNECT +import com.nice.cxonechat.exceptions.RuntimeChatException import com.nice.cxonechat.internal.ChatWithParameters import com.nice.cxonechat.state.Connection import com.nice.cxonechat.tool.SocketFactoryMock @@ -8,20 +28,55 @@ import kotlin.time.Duration.Companion.milliseconds internal abstract class AbstractChatTest : AbstractChatTestSubstrate() { - protected lateinit var connection: Connection protected lateinit var chat: Chat + protected var chatStateListener = FakeChatStateListener() + protected val connection: Connection + get() = (chat as ChatWithParameters).connection + protected open val authorization - get() = Authorization("", "") + get() = Authorization.None override fun prepare() { - chat = awaitResult(100.milliseconds) { + chat = awaitResult(100.milliseconds) { finished -> val factory = SocketFactoryMock(socket, proxyListener) ChatBuilder(entrails, factory) .setAuthorization(authorization) .setDevelopmentMode(true) - .build(it) + .setChatStateListener(chatStateListener) + .build { result: Result<Chat> -> + chat = result.getOrThrow() + chat.connect() + finished(chat) + } } - connection = (chat as ChatWithParameters).connection + } +} + +internal class FakeChatStateListener : ChatStateListener { + + var connection: ChatStateConnection = INITIAL + val onChatRuntimeExceptions = mutableListOf<RuntimeChatException>() + override fun onUnexpectedDisconnect() { + connection = UNEXPECTED_DISCONNECT + } + + override fun onConnected() { + connection = CONNECTED + } + + override fun onReady() { + connection = READY + } + + override fun onChatRuntimeException(exception: RuntimeChatException) { + onChatRuntimeExceptions.add(exception) + } + + enum class ChatStateConnection { + INITIAL, + UNEXPECTED_DISCONNECT, + CONNECTED, + READY, } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt index ff7492f7..cc3f85e6 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/AbstractChatTestSubstrate.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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 import androidx.annotation.CallSuper @@ -27,12 +12,13 @@ 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 io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify import okhttp3.WebSocket import org.junit.Before -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.spy -import org.mockito.kotlin.whenever import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -81,41 +67,38 @@ internal abstract class AbstractChatTestSubstrate { println(message) throwable?.printStackTrace() } - }.let(::spy) - - private fun mockStorage(): ValueStorage = mock<ValueStorage>().apply { - whenever(visitorId).thenReturn(UUID.fromString(TestUUID)) - whenever(customerId).thenReturn(UUID.fromString(TestUUID)) - whenever(destinationId).thenReturn(UUID.fromString(TestUUID)) - whenever(welcomeMessage).thenReturn("welcome") - whenever(authToken).thenReturn("token") - whenever(deviceToken).thenReturn("") + }.let(::spyk) + + // relaxUnitFun = true means we don't need to mock all the setters. + private fun mockStorage(): ValueStorage = mockk(relaxUnitFun = true) { + every { visitorId } returns UUID.fromString(TestUUID) + every { customerId } returns TestUUID + every { destinationId } returns UUID.fromString(TestUUID) + every { welcomeMessage } returns "welcome" + every { authToken } returns "token" + every { authTokenExpDate } returns null + every { deviceToken } returns "" } - private fun mockService() = mock<RemoteService>().apply { - val configurationCall = mock<Call<ChannelConfiguration?>>() - whenever(getChannel(any(), any())).thenReturn(configurationCall) - whenever(configurationCall.execute()).then { Response.success(config) } - whenever(configurationCall.enqueue(any())).then { mock -> - val callback = mock.getArgument<Callback<ChannelConfiguration?>>(0) + fun <T> mockCall(result: () -> T) = mockk<Call<T>> { + every { execute() } answers { Response.success(result()) } + every { enqueue(any()) } answers { + @Suppress("UNCHECKED_CAST") + val call = self as Call<T> + val callback = arg<Callback<T>>(0) + runCatching { config } - .onSuccess { callback.onResponse(configurationCall, Response.success(it)) } - .onFailure { callback.onFailure(configurationCall, it) } - Unit - } - val visitorCall = mock<Call<Void>>() - whenever( - createOrUpdateVisitor( - brandId = any(), - visitorId = any(), - visitor = any() - ) - ).thenReturn(visitorCall) - whenever(visitorCall.enqueue(any())).then { answer -> - answer.getArgument<Callback<Void>?>(0).onResponse(visitorCall, Response.success(null)) + .onSuccess { callback.onResponse(call, Response.success(result())) } + .onFailure { callback.onFailure(call, it) } } } + private fun mockService() = mockk<RemoteService> { + every { getChannel(any(), any()) } returns mockCall { config } + @Suppress("UNCHECKED_CAST") + every { createOrUpdateVisitor(any(), any(), any()) } returns mockCall { null } as Call<Void> + } + protected inline fun <T> testCallback( body: (trigger: (T) -> Unit) -> Any, serverAction: MockServer.() -> Unit, @@ -142,22 +125,33 @@ internal abstract class AbstractChatTestSubstrate { assertSendTexts(expected, except = except, replaceDate = replaceDate, body = expression) } + protected fun assertSendsNothing(body: () -> Unit) { + clearMocks(socket) + every { socket.send(text = any()) } returns true + + body() + + verify(exactly = 0) { socket.send(text = any()) } + } + protected fun assertSendTexts( vararg expected: String, except: Array<out String> = emptyArray(), replaceDate: Boolean = false, body: () -> Unit, ) { + clearMocks(socket) val arguments = mutableListOf<String>() - whenever(socket.send(text = any())).then { - arguments += it.getArgument<String>(0) - true - } + every { socket.send(text = capture(arguments)) } returns true + body() + assert(arguments.isNotEmpty()) { "Nothing was sent to the socket" } - val expectedArray = expected.map { if (replaceDate) replaceDate(it, emptyArray()) else it } + val expectedArray = expected + .map { if (replaceDate) replaceDate(it, emptyArray()) else it } + .map { replaceUUID(it, except) } arguments .map { replaceUUID(it, except) } .map { if (replaceDate) replaceDate(it, except) else it } @@ -167,7 +161,7 @@ internal abstract class AbstractChatTestSubstrate { } protected fun testSendTextFeedback() { - whenever(socket.send(text = any())).thenReturn(true) + every { socket.send(text = any()) } returns true } private fun replaceUUID(text: String, except: Array<out String>): String { diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatBackendErrorReportingTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatBackendErrorReportingTest.kt new file mode 100644 index 00000000..37156f86 --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatBackendErrorReportingTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2021-2023. 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 + +import com.nice.cxonechat.enums.ErrorType.ArchivingThreadFailed +import com.nice.cxonechat.enums.ErrorType.RecoveringLivechatFailed +import com.nice.cxonechat.enums.ErrorType.RecoveringThreadFailed +import com.nice.cxonechat.enums.ErrorType.SendingMessageFailed +import com.nice.cxonechat.enums.ErrorType.SendingOfflineMessageFailed +import com.nice.cxonechat.enums.ErrorType.SendingOutboundFailed +import com.nice.cxonechat.enums.ErrorType.SendingTranscriptFailed +import com.nice.cxonechat.enums.ErrorType.UpdatingThreadFailed +import com.nice.cxonechat.exceptions.RuntimeChatException +import com.nice.cxonechat.server.ServerResponse +import org.junit.Test +import kotlin.test.assertEquals + +internal class ChatBackendErrorReportingTest : AbstractChatTest() { + + @Test + fun test_SendingMessageFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(SendingMessageFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(SendingMessageFailed.value, last) + } + + @Test + fun test_RecoveringLivechatFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(RecoveringLivechatFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(RecoveringLivechatFailed.value, last) + } + + @Test + fun test_RecoveringThreadFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(RecoveringThreadFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(RecoveringThreadFailed.value, last) + } + + @Test + fun test_SendingOutboundFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(SendingOutboundFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(SendingOutboundFailed.value, last) + } + + @Test + fun test_UpdatingThreadFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(UpdatingThreadFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(UpdatingThreadFailed.value, last) + } + + @Test + fun test_ArchivingThreadFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(ArchivingThreadFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(ArchivingThreadFailed.value, last) + } + + @Test + fun test_SendingTranscriptFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(SendingTranscriptFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(SendingTranscriptFailed.value, last) + } + + @Test + fun test_SendingOfflineMessageFailed_is_reported() { + this serverResponds ServerResponse.ErrorResponse(SendingOfflineMessageFailed.value) + val last = this.chatStateListener.onChatRuntimeExceptions.last() + assertServerCommunicationError(SendingOfflineMessageFailed.value, last) + } + + private fun assertServerCommunicationError(expectedMessage: String, actualException: RuntimeChatException) { + assertEquals(RuntimeChatException.ServerCommunicationError::class, actualException::class) + assertEquals(expectedMessage, actualException.message) + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatBuilderTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatBuilderTest.kt index 372181ba..79b8ab9f 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatBuilderTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatBuilderTest.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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. - */ - @file:Suppress("FunctionMaxLength") package com.nice.cxonechat @@ -28,19 +13,15 @@ import com.nice.cxonechat.state.Connection import com.nice.cxonechat.tool.SocketFactoryMock import com.nice.cxonechat.tool.awaitResult import com.nice.cxonechat.tool.nextString +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Test -import org.mockito.ArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import retrofit2.Call import retrofit2.Response import java.io.IOException -import java.util.Date import java.util.UUID import kotlin.test.assertEquals @@ -61,68 +42,93 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { } @Test - fun build_recoversIOException() { - val call = mock<Call<ChannelConfiguration?>>() + fun build_handlesIOException() { var thrownException = false - whenever(service.getChannel(any(), any())).thenReturn(call) - whenever(call.execute()).then { - if (!thrownException) { - thrownException = true - throw IOException() - } else { - Response.success(config) + val exception = IOException() + + val call = mockk<Call<ChannelConfiguration?>> { + every { execute() } answers { + if (!thrownException) { + thrownException = true + throw exception + } else { + Response.success(config) + } } } - build() + every { service.getChannel(any(), any()) } returns call + + val result = build() + assert(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + val secondResult = build() + assert(secondResult.isSuccess) } @Test - fun build_recoversRuntimeException() { - val call = mock<Call<ChannelConfiguration?>>() + fun build_handlesRuntimeException() { var thrownException = false - whenever(service.getChannel(any(), any())).thenReturn(call) - whenever(call.execute()).then { - @Suppress("TooGenericExceptionThrown") - if (!thrownException) { - thrownException = true - throw RuntimeException() - } else { - Response.success(config) + val exception = RuntimeException() + + val call = mockk<Call<ChannelConfiguration?>> { + every { execute() } answers { + @Suppress("TooGenericExceptionThrown") + if (!thrownException) { + thrownException = true + throw exception + } else { + Response.success(config) + } } } - build() + every { service.getChannel(any(), any()) } returns call + + val result = build() + assert(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + val secondResult = build() + assert(secondResult.isSuccess) } @Test - fun build_recoversFailure() { - val call = mock<Call<ChannelConfiguration?>>() + fun build_handlesFailure() { var returnedFailure = false - whenever(service.getChannel(any(), any())).thenReturn(call) - whenever(call.execute()).then { - if (!returnedFailure) { - returnedFailure = true - Response.error(500, "".toResponseBody()) - } else { - Response.success(config) + val call = mockk<Call<ChannelConfiguration?>> { + every { execute() } answers { + if (!returnedFailure) { + returnedFailure = true + Response.error(500, "".toResponseBody()) + } else { + Response.success(config) + } } } - build() + every { service.getChannel(any(), any()) } returns call + + val result = build() + assert(result.isFailure) + val secondResult = build() + assert(secondResult.isSuccess) } @Test - fun build_recoversInvalidBody() { - val call = mock<Call<ChannelConfiguration?>>() + fun build_handlesInvalidBody() { var returnedFailure = false - whenever(service.getChannel(any(), any())).thenReturn(call) - whenever(call.execute()).then { - if (!returnedFailure) { - returnedFailure = true - Response.success(null) - } else { - Response.success(config) + val call = mockk<Call<ChannelConfiguration?>> { + every { execute() } answers { + if (!returnedFailure) { + returnedFailure = true + Response.success(null) + } else { + Response.success(config) + } } } - build() + every { service.getChannel(any(), any()) } returns call + val result = build() + assert(result.isFailure) + val secondResult = build() + assert(secondResult.isSuccess) } @Test @@ -133,22 +139,25 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { assertSendTexts( ServerRequest.AuthorizeConsumer(connection, code = code, verifier = verifier), ) { - build(builder) { - whenever(storage.authToken).thenReturn(null) - whenever(storage.customerId).thenReturn(null) + connect(builder) { + every { storage.authToken } returns null + every { storage.customerId } returns null setAuthorization(Authorization(code, verifier)) } } - verify(service, times(1)).createOrUpdateVisitor(any(), any(), any()) + + verify(exactly = 1) { + service.createOrUpdateVisitor(any(), any(), any()) + } } @Test fun build_authorization_updatesConnection() { - val uuid = UUID.randomUUID() + val uuid = UUID.randomUUID().toString() val firstName = "new-first-name" val lastName = "new-last-name" val (connection, builder) = prepareBuilder() - val chat = build(builder) + val chat = connect(builder) // updates connection this serverResponds ServerResponse.ConsumerAuthorized(uuid, firstName, lastName) @@ -163,10 +172,11 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { @Test fun build_authorization_updatesStorage_consumer() { - val uuid = UUID.randomUUID() + val uuid = UUID.randomUUID().toString() build() this serverResponds ServerResponse.ConsumerAuthorized(uuid) - verify(storage).customerId = uuid + + verify { storage.customerId = uuid } } @Test @@ -174,23 +184,23 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { val token = nextString() build() this serverResponds ServerResponse.ConsumerAuthorized(accessToken = token) - verify(storage).authToken = token + + verify { storage.authToken = token } } @Test fun build_authorization_updatesStorage_tokenExpDate() { - val captor = ArgumentCaptor.forClass(Date::class.java) build() this serverResponds ServerResponse.ConsumerAuthorized() - verify(storage).authTokenExpDate = captor.capture() + verify(exactly = 1) { storage.authTokenExpDate = any() } } @Test fun build_sets_consumerId_fromStorage() { - val expected = UUID.randomUUID() - whenever(storage.customerId).thenReturn(expected) + val expected = UUID.randomUUID().toString() + every { storage.customerId } returns expected val (connection, builder) = prepareBuilder() - val chat = build(builder) + val chat = connect(builder) val thread = makeChatThread() assertSendText(ServerRequest.ArchiveThread(connection, thread), expected.toString(), thread.id.toString()) { chat.threads().thread(thread).events().trigger(ArchiveThreadEvent) @@ -203,12 +213,14 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { assertSendTexts( ServerRequest.ReconnectConsumer(connection), ) { - build(builder) { - whenever(storage.authToken).thenReturn("token") + connect(builder) { + every { storage.authToken } returns "token" + every { storage.authTokenExpDate } returns null this } } - verify(service, times(1)).createOrUpdateVisitor(any(), any(), any()) + + verify(exactly = 1) { service.createOrUpdateVisitor(any(), any(), any()) } } @Test @@ -216,7 +228,8 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { val expected = "Welcome, how was your day?" build() this serverResponds ServerResponse.WelcomeMessage(expected) - verify(storage).welcomeMessage = expected + + verify { storage.welcomeMessage = expected } } @Test @@ -225,7 +238,7 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { val lastName = nextString() val chat = build { setUserName(firstName, lastName) - } as ChatWithParameters + }.getOrThrow() as ChatWithParameters val connection = chat.connection assertEquals(firstName, connection.firstName) assertEquals(lastName, connection.lastName) @@ -233,7 +246,7 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { @Test fun build_keeps_username_if_not_set() { - val chat = build() as ChatWithParameters + val chat = build().getOrThrow() as ChatWithParameters val connection = chat.connection assertEquals(SocketFactoryMock.firstName, connection.firstName) assertEquals(SocketFactoryMock.lastName, connection.lastName) @@ -244,10 +257,10 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { isAuthorizationEnabled = false val firstName = nextString() val lastName = nextString() - val uuid = UUID.randomUUID() + val uuid = UUID.randomUUID().toString() val empty = "" val (connection, builder) = prepareBuilder() - val chat = build(builder) { + val chat = connect(builder) { setUserName(firstName, lastName) } as ChatWithParameters @@ -268,14 +281,16 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { val token = UUID.randomUUID().toString() builder.setDeviceToken(token) build(builder) - verify(storage, times(1)).deviceToken = token + + verify(exactly = 1) { storage.deviceToken = token } } @Test fun build_keeps_deviceToken_if_not_set() { val (_, builder) = prepareBuilder() build(builder) - verify(storage, times(0)).deviceToken = any() + + verify(exactly = 0) { storage.deviceToken = any() } } // --- @@ -289,10 +304,24 @@ internal class ChatBuilderTest : AbstractChatTestSubstrate() { private fun build( builder: ChatBuilder = prepareBuilder().second, body: ChatBuilder.() -> ChatBuilder = { this }, - ): Chat = awaitResult { + ): Result<Chat> = awaitResult { builder .setDevelopmentMode(true) .body() .build(it) } + + private fun connect( + builder: ChatBuilder = prepareBuilder().second, + body: ChatBuilder.() -> ChatBuilder = { this }, + ): Chat = awaitResult { finish -> + builder + .setDevelopmentMode(true) + .body() + .build { result: Result<Chat> -> + val chat = result.getOrThrow() + chat.connect() + finish(chat) + } + } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt index 0c2f5235..be75697b 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerActionsTest.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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 import com.google.gson.GsonBuilder @@ -64,37 +49,38 @@ internal class ChatEventHandlerActionsTest { .setLenient() .create() } - private val kVisitorId = UUID.randomUUID() - private val kCustomerId = UUID.randomUUID() - private val kDestinationId = UUID.randomUUID() - private val kVisitId = UUID.randomUUID() - private val kEventId = UUID.randomUUID() - private val kBrandId = Random.nextInt() - private val kNow = Date() + private val visitorId = UUID.randomUUID() + private val customerId = UUID.randomUUID().toString() + private val destinationId = UUID.randomUUID() + private val visitId = UUID.randomUUID() + private val eventId = UUID.randomUUID() + private val brandId = Random.nextInt() + private val now = Date() private val interceptor by lazy { MockInterceptor() } private val httpClient by lazy { OkHttpClient().newBuilder().addInterceptor(interceptor).build() } - private val kBaseUrl = "https://chat.server/" - private val kChatUrl by lazy { "${kBaseUrl}chat/" } - private val kAnalyticsUrl by lazy { "${kBaseUrl}web-analytics/1.0/tenants/$kBrandId/visitors/$kVisitorId/events" } + private val baseUrl = "https://chat.server/" + private val chatUrl by lazy { "${baseUrl}chat/" } + private val analyticsUrl by lazy { "${baseUrl}web-analytics/1.0/tenants/$brandId/visitors/$visitorId/events" } private val mockEnvironment by lazy { mockk<Environment> { - every { chatUrl } returns kChatUrl + every { chatUrl } returns this@ChatEventHandlerActionsTest.chatUrl } } private val mockConnection by lazy { mockk<Connection> { every { environment } returns mockEnvironment - every { brandId } returns kBrandId + every { brandId } returns this@ChatEventHandlerActionsTest.brandId } } private val mockStorage by lazy { + val local = this mockk<ValueStorage> { - every { visitorId } returns kVisitorId - every { customerId } returns kCustomerId - every { destinationId } returns kDestinationId + every { visitorId } returns local.visitorId + every { customerId } returns local.customerId + every { destinationId } returns local.destinationId every { welcomeMessage } returns "welcome" every { authToken } returns "token" - every { visitId } returns kVisitId + every { visitId } returns local.visitId } } private val mockService by lazy { @@ -133,13 +119,13 @@ internal class ChatEventHandlerActionsTest { ) private fun verifyEventSent(expect: AnalyticsEvent, send: ChatEventHandler.(done: () -> Unit) -> Unit) { - awaitResult(100.milliseconds) { done -> + awaitResult(200.milliseconds) { done -> events.send { done(Unit) } } assertEquals(1, interceptor.requests.size) with(interceptor.requests.first()) { assertEquals("POST", method) - assertEquals(kAnalyticsUrl, url.toString()) + assertEquals(analyticsUrl, url.toString()) val actual = gson.fromJson(this.body!!.asString, AnalyticsEvent::class.java) @@ -152,11 +138,11 @@ internal class ChatEventHandlerActionsTest { private fun event(type: VisitorEventType, data: Any = mapOf<String, Any>()): AnalyticsEvent { return AnalyticsEvent( - kEventId, + eventId, type, - kVisitId, - Destination(kDestinationId), - kNow, + visitId, + Destination(destinationId), + now, gson.toJson(data).let { gson.fromJson(it, Map::class.java) } ) } @@ -165,10 +151,10 @@ internal class ChatEventHandlerActionsTest { fun conversion() { val expect = event( Conversion, - ConversionModel("cash", 324, kNow) + ConversionModel("cash", 324, now) ) verifyEventSent(expect) { done -> - events.conversion("cash", 324, kNow) { done() } + events.conversion("cash", 324, now) { done() } } } @@ -179,7 +165,7 @@ internal class ChatEventHandlerActionsTest { PageViewData("some title", "https://some.url/or/other") ) verifyEventSent(expect) { done -> - events.pageView("some title", "https://some.url/or/other", kNow) { done() } + events.pageView("some title", "https://some.url/or/other", now) { done() } } } @@ -187,7 +173,7 @@ internal class ChatEventHandlerActionsTest { fun proactiveActionClick() { val expect = event(ProactiveActionClicked, actionMetaData) verifyEventSent(expect) { done -> - events.proactiveActionClick(actionMetaData, kNow) { done() } + events.proactiveActionClick(actionMetaData, now) { done() } } } @@ -195,7 +181,7 @@ internal class ChatEventHandlerActionsTest { fun proactiveActionDisplay() { val expect = event(ProactiveActionDisplayed, actionMetaData) verifyEventSent(expect) { done -> - events.proactiveActionDisplay(actionMetaData, kNow) { done() } + events.proactiveActionDisplay(actionMetaData, now) { done() } } } @@ -203,7 +189,7 @@ internal class ChatEventHandlerActionsTest { fun proactiveActionFailure() { val expect = event(ProactiveActionFailed, actionMetaData) verifyEventSent(expect) { done -> - events.proactiveActionFailure(actionMetaData, kNow) { done() } + events.proactiveActionFailure(actionMetaData, now) { done() } } } @@ -211,7 +197,7 @@ internal class ChatEventHandlerActionsTest { fun proactiveActionSuccess() { val expect = event(ProactiveActionSuccess, actionMetaData) verifyEventSent(expect) { done -> - events.proactiveActionSuccess(actionMetaData, kNow) { done() } + events.proactiveActionSuccess(actionMetaData, now) { done() } } } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerTest.kt index e63b50bb..5b657192 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventHandlerTest.kt @@ -2,8 +2,8 @@ package com.nice.cxonechat import com.nice.cxonechat.event.ChatEvent import com.nice.cxonechat.server.ServerRequest +import io.mockk.every import org.junit.Test -import org.mockito.kotlin.whenever import java.util.Date internal class ChatEventHandlerTest : AbstractChatTest() { @@ -26,7 +26,7 @@ internal class ChatEventHandlerTest : AbstractChatTest() { @Test fun trigger_refreshesToken() { - whenever(storage.authTokenExpDate).thenReturn(Date()) + every { storage.authTokenExpDate } returns Date() assertSendTexts( ServerRequest.RefreshToken(connection), """{"field":104}""" diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventTest.kt index 0c6ccdef..f9e3b43e 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatEventTest.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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. - */ - @file:Suppress("FunctionMaxLength") package com.nice.cxonechat diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatInstanceProviderTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatInstanceProviderTest.kt new file mode 100644 index 00000000..5357c785 --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatInstanceProviderTest.kt @@ -0,0 +1,845 @@ +/* + * Copyright (c) 2021-2023. 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 + +import android.annotation.SuppressLint +import android.content.Context +import com.nice.cxonechat.ChatBuilder.OnChatBuiltResultCallback +import com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider +import com.nice.cxonechat.ChatInstanceProvider.Listener +import com.nice.cxonechat.ChatState.CONNECTED +import com.nice.cxonechat.ChatState.CONNECTING +import com.nice.cxonechat.ChatState.CONNECTION_LOST +import com.nice.cxonechat.ChatState.INITIAL +import com.nice.cxonechat.ChatState.PREPARED +import com.nice.cxonechat.ChatState.PREPARING +import com.nice.cxonechat.ChatState.READY +import com.nice.cxonechat.exceptions.InvalidCustomFieldValue +import com.nice.cxonechat.exceptions.InvalidStateException +import com.nice.cxonechat.exceptions.RuntimeChatException +import com.nice.cxonechat.log.Level +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.state.Environment +import com.nice.cxonechat.state.FieldDefinition +import io.mockk.Ordering.ORDERED +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import io.mockk.verifyOrder +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@Suppress("LargeClass", "StringLiteralDuplication") +internal class ChatInstanceProviderTest { + private val applicationContext = mockk<Context>() + private val socketEnvironment: Environment by lazy { + mockk() + } + private val socketFactoryConfiguration by lazy { + object : SocketFactoryConfiguration { + override val environment = socketEnvironment + override val brandId = BRAND_ID + override val channelId = CHANNEL_ID + + @Deprecated("This field is deprecated for public usage and will be removed from public API.") + override val version = VERSION + } + } + + private fun provider( + socketFactoryConfiguration: SocketFactoryConfiguration? = this.socketFactoryConfiguration, + onBuilt: (Chat, OnChatBuiltResultCallback?) -> Cancellable = { chat, callback -> + callback?.onChatBuiltResult(Result.success(chat)) + Cancellable.noop + }, + logger: Logger = mockk(relaxed = true), + onConnected: (ChatStateListener?) -> Cancellable = { Cancellable.noop }, + ): Pair<ChatInstanceProvider, ChatBuilder> { + val builder = mockk<ChatBuilder> { + val builder = this + var listener: ChatStateListener? = null + + every { setUserName(any(), any()) } returns this + every { setAuthorization(any()) } returns this + every { setDevelopmentMode(any()) } returns this + every { setChatStateListener(any()) } answers { + listener = arg<ChatStateListener?>(0) + builder + } + every { setDeviceToken(any()) } returns this + every { build(resultCallback = any()) } answers { call -> + val onDone = arg<OnChatBuiltResultCallback?>(0) + val chat = mockk<Chat>(relaxUnitFun = true) { + every { connect() } answers { onConnected(listener) } + every { configuration } answers { + mockk { + every { isAuthorizationEnabled } returns false + } + } + } + + onBuilt(chat, onDone) + } + } + val provider = ChatInstanceProvider.create(socketFactoryConfiguration, logger = logger) { _, _, _ -> + builder + } + + return Pair(provider, builder) + } + + @Test + fun createRegistersInstance() { + assertSame( + ChatInstanceProvider.create(null), + ChatInstanceProvider.get(), + "ChatInstanceProvider.create() should return same object as ChatInstanceProvider.get()" + ) + } + + @Test + fun initialStateIsInitial() { + assertEquals( + ChatInstanceProvider.create(null).chatState, + INITIAL + ) + } + + @Test(expected = InvalidStateException::class) + fun prepareThrowsWithNoConfiguration() { + provider(null).first.prepare(mockk()) + } + + @Test(expected = InvalidStateException::class) + fun prepareThrowsWhenConnected() { + val (provider) = provider(socketFactoryConfiguration) + + provider.onConnected() + + provider.prepare(applicationContext) + } + + @Test + fun configurationAuthenticationRequiredDefaultsFalse() { + val (provider) = provider(socketFactoryConfiguration) + + provider.configure(applicationContext) { + assertFalse(authenticationRequired) + } + } + + @Test + fun configureAccessAuthenticationRequired() { + listOf(true, false).forEach { required -> + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + + val chat = requireNotNull(provider.chat) + + every { chat.configuration.isAuthorizationEnabled } returns required + + provider.configure(applicationContext) { + assertEquals(required, authenticationRequired) + } + } + } + + @Test + fun configureSetsConfiguration() { + val (provider, _) = provider(null) + + provider.configure(applicationContext) { + configuration = socketFactoryConfiguration + assertSame(socketFactoryConfiguration, configuration) + } + + assertSame(provider.configuration, socketFactoryConfiguration) + } + + @Test + fun configureSetsUserName() { + val (provider, _) = provider(socketFactoryConfiguration) + val expect = UserName("first name", "last name") + + provider.configure(applicationContext) { + userName = expect + assertSame(expect, userName) + } + + assertEquals(expect, provider.userName) + } + + @Test + fun configureSetsLogger() { + val (provider, _) = provider(socketFactoryConfiguration) + val expect = mockk<Logger>() + + provider.configure(applicationContext) { + logger = expect + assertSame(expect, logger) + } + + assertEquals(expect, provider.logger) + } + + @Test + fun configureSetsAuthorization() { + val (provider, _) = provider(socketFactoryConfiguration) + val expect = Authorization("code", "verifier") + + provider.configure(applicationContext) { + authorization = expect + assertSame(expect, authorization) + } + + assertEquals(expect, provider.authorization) + } + + @Test + fun configureSetsDevelopmentMode() { + val (provider, _) = provider(socketFactoryConfiguration) + val expect = !provider.developmentMode + + provider.configure(applicationContext) { + developmentMode = expect + assertEquals(expect, developmentMode) + } + + assertEquals(expect, provider.developmentMode) + } + + @Test + fun configureSetsTokenProvider() { + val (provider, _) = provider(socketFactoryConfiguration) + val expect = mockk<DeviceTokenProvider> { + every { requestDeviceToken(any()) } just runs + } + + provider.configure(applicationContext) { + deviceTokenProvider = expect + assertSame(expect, deviceTokenProvider) + } + + assertSame(expect, provider.deviceTokenProvider) + } + + @Test + fun configureRestartsChat() { + val (provider, _) = provider(socketFactoryConfiguration) + + assertNull(provider.chat) + + provider.configure(applicationContext) {} + + val chat = requireNotNull(provider.chat) + + provider.configure(applicationContext) {} + + verify { + chat.signOut() + } + + assertNotSame(chat, provider.chat) + assertEquals(PREPARED, provider.chatState) + } + + @SuppressLint("CheckResult") + @Test + fun configureSetsUpBuilder() { + val token = "token" + val tokenProvider = DeviceTokenProvider { it(token) } + val (provider, builder) = provider(socketFactoryConfiguration) + val auth = Authorization("code", "verifier") + + provider.configure(applicationContext) { + developmentMode = false + userName = UserName(lastName = "last", firstName = "first") + authorization = auth + deviceTokenProvider = tokenProvider + } + + verifyOrder { + // Note that the order of the sets is not required, it is just required that + // they all occur before the build call. + builder.setChatStateListener(provider) + builder.setDevelopmentMode(false) + builder.setUserName("first", "last") + builder.setAuthorization(auth) + builder.setDeviceToken(token) + builder.build(resultCallback = any()) + provider.chat?.setDeviceToken(token) + } + } + + @Test + @SuppressLint("CheckResult") + fun prepareAdvancesChatState() { + val (provider, builder) = provider(socketFactoryConfiguration) + + val listener = mockk<Listener> { + every { onChatChanged(any()) } just runs + every { onChatStateChanged(any()) } just runs + every { onChatStateChanged(any()) } just runs + }.also(provider::addListener) + + provider.prepare(applicationContext) + + verify(ordering = ORDERED) { + builder.build(resultCallback = any()) + listener.onChatChanged(any()) + listener.onChatStateChanged(PREPARED) + } + } + + @Test + fun connectionLostCancels() { + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + provider.connect() + provider.onUnexpectedDisconnect() + provider.cancel() + + assertEquals(PREPARED, provider.chatState) + } + + @Test + fun initialIgnoresCancel() { + val (provider) = provider(null) + + assertEquals(INITIAL, provider.chatState) + + provider.cancel() + + assertEquals(INITIAL, provider.chatState) + } + + @Test + fun preparedIgnoresCancel() { + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + + assertEquals(PREPARED, provider.chatState) + + provider.cancel() + + assertEquals(PREPARED, provider.chatState) + } + + @Test + fun connectedIgnoresCancel() { + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + provider.connect() + provider.onConnected() + + assertEquals(CONNECTED, provider.chatState) + + provider.cancel() + + assertEquals(CONNECTED, provider.chatState) + } + + @SuppressLint("CheckResult") + @Test + fun duplicatePrepareIgnored() { + val (provider, builder) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + + provider.prepare(applicationContext) + + verify(exactly = 1) { + builder.build(resultCallback = any()) + } + } + + @Test + fun setUserNameForwardsToChat() { + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + + provider.setUserName(UserName(lastName = "last", firstName = "first")) + + verify { + provider.chat!!.setUserName("first", "last") + } + } + + @Test(expected = InvalidStateException::class) + fun connectThrowsInInitial() { + val (provider) = provider(null) + + assertEquals(INITIAL, provider.chatState) + + provider.connect() + } + + @Test(expected = InvalidStateException::class) + fun connectThrowsInPreparing() { + val (provider) = provider( + socketFactoryConfiguration, + onBuilt = { _, _ -> Cancellable { } } + ) + + provider.prepare(applicationContext) + + assertEquals(PREPARING, provider.chatState) + + provider.connect() + } + + @Test(expected = InvalidStateException::class) + fun connectThrowsWhenConnecting() { + val (provider) = provider(socketFactoryConfiguration) { + Cancellable { } + } + + provider.prepare(applicationContext) + provider.connect() + provider.connect() + } + + @Test + fun connectIgnoredWhenConnected() { + val logger: Logger = mockk(relaxed = true) + val (provider) = provider(socketFactoryConfiguration, logger = logger) + + provider.prepare(applicationContext) + provider.onConnected() + provider.connect() + + verify(exactly = 0) { requireNotNull(provider.chat).connect() } + + verify { + logger.log(Level.Warning, any(), any()) + } + } + + @Test + fun onConnectedAdvancesState() { + val (provider) = provider(socketFactoryConfiguration) + val listener = mockk<Listener>(relaxUnitFun = true) + + provider.addListener(listener) + // this does nothing, but it makes coverage happy + provider.addListener(object : Listener {}) + provider.prepare(applicationContext) + + provider.onConnected() + + verify { + listener.onChatStateChanged(CONNECTED) + } + } + + @Test + fun onReadyAdvancesState() { + val (provider) = provider(socketFactoryConfiguration) + val listener = mockk<Listener>(relaxUnitFun = true) + + provider.addListener(listener) + // this does nothing, but it makes coverage happy + provider.addListener(object : Listener {}) + provider.prepare(applicationContext) + + provider.onReady() + + verify { + listener.onChatStateChanged(READY) + } + } + + @Test + fun onDisconnectAdvancesState() { + val (provider) = provider(socketFactoryConfiguration) + val listener = mockk<Listener>(relaxUnitFun = true) + + provider.addListener(listener) + // this does nothing, but it makes coverage happy + provider.addListener(object : Listener {}) + provider.prepare(applicationContext) + + provider.onUnexpectedDisconnect() + + verify { + listener.onChatStateChanged(CONNECTION_LOST) + } + } + + @Test + fun onExceptionForwards() { + val (provider) = provider(socketFactoryConfiguration) + val listener = mockk<Listener>(relaxUnitFun = true) + val exception = mockk<RuntimeChatException>() + + provider.addListener(listener) + // this does nothing, but it makes coverage happy + provider.addListener(object : Listener {}) + provider.prepare(applicationContext) + + provider.onChatRuntimeException(exception) + + verify { + listener.onChatRuntimeException(exception) + } + } + + @Test + fun closeClosesChat() { + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + provider.connect() + val chat = requireNotNull(provider.chat) + provider.close() + + verify { + chat.close() + } + + assertEquals(PREPARED, provider.chatState) + } + + @Test + fun removeListener() { + val (provider) = provider(socketFactoryConfiguration) + val listener = mockk<Listener>(relaxUnitFun = true) + + provider.addListener(listener) + provider.removeListener(listener) + provider.onConnected() + + verify(exactly = 0) { + listener.onChatStateChanged(any()) + } + } + + @Test + fun prepareAdvancesStateAndCancels() { + val cancellable = mockk<Cancellable>(relaxUnitFun = true) + val (provider) = provider( + socketFactoryConfiguration, + onBuilt = { chat, onDone -> + onDone?.onChatBuiltResult(Result.success(chat)) + cancellable + } + ) + + provider.prepare(applicationContext) + + assertEquals(PREPARING, provider.chatState) + + provider.cancel() + + verify { + cancellable.cancel() + } + assertEquals(INITIAL, provider.chatState) + } + + @Test + fun connectAdvancesStateAndCancels() { + val cancellable = mockk<Cancellable>(relaxUnitFun = true) + val (provider) = provider( + socketFactoryConfiguration, + onConnected = { + cancellable + }, + ) + + provider.prepare(applicationContext) + provider.connect() + + assertEquals(CONNECTING, provider.chatState) + + provider.cancel() + + verify { + cancellable.cancel() + } + + assertEquals(PREPARED, provider.chatState) + } + + @Test(expected = InvalidStateException::class) + fun reconnectThrowsInInitial() { + val (provider) = provider(null) + + assertEquals(INITIAL, provider.chatState) + + @Suppress("DEPRECATION") + provider.reconnect() + } + + @Test(expected = InvalidStateException::class) + fun reconnectThrowsInPreparing() { + val (provider) = provider( + socketFactoryConfiguration, + onBuilt = { _, _ -> Cancellable { } } + ) + + provider.prepare(applicationContext) + + assertEquals(PREPARING, provider.chatState) + + @Suppress("DEPRECATION") + provider.reconnect() + } + + @Test(expected = InvalidStateException::class) + fun reconnectThrowsInPrepared() { + val (provider) = provider( + socketFactoryConfiguration, + ) + + provider.prepare(applicationContext) + + assertEquals(PREPARED, provider.chatState) + + @Suppress("DEPRECATION") + provider.reconnect() + } + + @Test(expected = InvalidStateException::class) + fun reconnectThrowsInConnecting() { + val (provider) = provider( + socketFactoryConfiguration, + onConnected = { _ -> Cancellable { } } + ) + + provider.prepare(applicationContext) + provider.connect() + + assertEquals(CONNECTING, provider.chatState) + + @Suppress("DEPRECATION") + provider.reconnect() + } + + @Test(expected = InvalidStateException::class) + fun reconnectThrowsInConnected() { + val (provider) = provider( + socketFactoryConfiguration, + ) + + provider.prepare(applicationContext) + provider.connect() + provider.onConnected() + + assertEquals(CONNECTED, provider.chatState) + @Suppress("DEPRECATION") + provider.reconnect() + } + + @Test + fun reconnectConnects() { + val (provider) = provider( + socketFactoryConfiguration, + onConnected = { _ -> Cancellable { } } + ) + + provider.prepare(applicationContext) + provider.connect() + provider.onConnected() + provider.onUnexpectedDisconnect() + + assertEquals(CONNECTION_LOST, provider.chatState) + @Suppress("DEPRECATION") + provider.reconnect() + + assertEquals(CONNECTING, provider.chatState) + + provider.onConnected() + + assertEquals(CONNECTED, provider.chatState) + } + + @Test + fun setCustomerValuesSurvivesNoChat() { + val (provider) = provider(socketFactoryConfiguration) + + provider.setCustomerValues(mapOf()) + } + + @Test + fun setCustomerValuesStripsUnsupportedFields() { + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + + val chat = requireNotNull(provider.chat) + val customFields = mockk<ChatFieldHandler> { + every { add(any()) } just runs + } + + every { chat.customFields() } returns customFields + every { chat.configuration.customerCustomFields } returns sequenceOf( + object : FieldDefinition { + override val fieldId: String = "fieldId" + override val label: String = "some label" + override val isRequired: Boolean = false + + override fun validate(value: String) = Unit + } + ) + + provider.setCustomerValues( + mapOf( + "unsupported" to "value", + "fieldId" to "other value", + ) + ) + + verify { + customFields.add(mapOf("fieldId" to "other value")) + } + } + + @Test + fun setCustomerValuesSurvivesInvalidFields() { + val (provider) = provider(socketFactoryConfiguration) + + provider.prepare(applicationContext) + + val chat = requireNotNull(provider.chat) + val customFields = mockk<ChatFieldHandler> { + every { add(any()) } just runs + } + + every { chat.customFields() } returns customFields + every { chat.configuration.customerCustomFields } returns sequenceOf( + object : FieldDefinition { + override val fieldId: String = "fieldId" + override val label: String = "some label" + override val isRequired: Boolean = false + + override fun validate(value: String) { + throw InvalidCustomFieldValue("some label", "some error") + } + } + ) + + provider.setCustomerValues( + mapOf( + "fieldId" to "other value", + ) + ) + + verify(exactly = 0) { + customFields.add(any()) + } + } + + @Test + fun advanceState_cancels() { + val provider = ChatInstanceProvider.create(null) + val cancellable = mockk<Cancellable> { + every { cancel() } just Runs + } + + provider.advanceState(CONNECTING, cancellable, false) + + provider.advanceState(CONNECTED) + + verify { + cancellable.cancel() + } + } + + @Test + fun advanceState_requiredCancellables() { + for(state in ChatState.entries) { + val provider = ChatInstanceProvider.create(null) + var thrown = false + + if (state == INITIAL) { + provider.advanceState(CONNECTION_LOST) + } + + @Suppress("SwallowedException") + try { + provider.advanceState(state) + } catch (exc: AssertionError) { + thrown = true + } + + if (setOf(CONNECTING, PREPARING).contains(state)) { + assertTrue(thrown, "Missing expected exception for null cancellable in $state") + } else { + assertFalse(thrown, "Unexpected exception for null cancellable in $state") + } + } + } + + @Test + fun advanceState_disallowedCancellables() { + for(state in ChatState.entries) { + val provider = ChatInstanceProvider.create(null) + var thrown = false + + if (state == INITIAL) { + provider.advanceState(CONNECTION_LOST) + } + + @Suppress("SwallowedException") + try { + provider.advanceState(state, mockk()) + } catch (exc: AssertionError) { + thrown = true + } + + if (setOf(CONNECTING, PREPARING).contains(state)) { + assertFalse(thrown, "Unexpected exception for disallowed cancellable in $state") + } else { + assertTrue(thrown, "Missing expected exception for disallowed cancellable in $state") + } + } + } + + @Test + fun advanceState_notifiesListeners() { + val provider = ChatInstanceProvider.create(null) + val listener = mockk<ChatInstanceProvider.Listener> { + every { onChatStateChanged(any()) } just Runs + }.also(provider::addListener) + + provider.advanceState(CONNECTED) + + verify { + listener.onChatStateChanged(CONNECTED) + } + } + + companion object { + private const val BRAND_ID = 1000L + private val CHANNEL_ID = UUID.randomUUID().toString() + private const val VERSION = "1.0" + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatSingleThreadTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatSingleThreadTest.kt new file mode 100644 index 00000000..38c0c2ab --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatSingleThreadTest.kt @@ -0,0 +1,59 @@ +/* + * 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 + +import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection +import com.nice.cxonechat.internal.model.ChannelConfiguration +import com.nice.cxonechat.model.makeChatThread +import com.nice.cxonechat.model.makeMessageModel +import com.nice.cxonechat.server.ServerRequest +import com.nice.cxonechat.server.ServerResponse +import org.junit.Test +import kotlin.test.assertEquals + +internal class ChatSingleThreadTest : AbstractChatTest() { + + override val config: ChannelConfiguration? + get() { + val config = super.config + return config?.copy( + settings = config.settings.copy(hasMultipleThreadsPerEndUser = false) + ) + } + + @Test + fun chat_is_ready_on_no_thread() { + prepare() + assertEquals(ChatStateConnection.INITIAL, chatStateListener.connection) + serverResponds(ServerResponse.ThreadListFetched(emptyList())) + assertEquals(ChatStateConnection.READY, chatStateListener.connection) + } + + @Test + fun chat_attempts_to_recover_thread() { + val thread = makeChatThread() + assertSendTexts( + ServerRequest.LoadThreadMetadata(connection, thread), + ServerRequest.RecoverThread(connection, thread), + ) { + assertEquals(ChatStateConnection.INITIAL, chatStateListener.connection) + serverResponds(ServerResponse.ThreadListFetched(listOf(thread))) + serverResponds(ServerResponse.ThreadMetadataLoaded(message = makeMessageModel(threadIdOnExternalPlatform = thread.id))) + serverResponds(ServerResponse.ThreadRecovered(thread = thread)) + assertEquals(ChatStateConnection.READY, chatStateListener.connection) + } + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatTest.kt index 1f337d5c..63e119a1 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatTest.kt @@ -17,76 +17,108 @@ package com.nice.cxonechat +import com.nice.cxonechat.ChatMode.MULTI_THREAD +import com.nice.cxonechat.ChatMode.SINGLE_THREAD +import com.nice.cxonechat.enums.ErrorType.ConsumerReconnectionFailed +import com.nice.cxonechat.enums.ErrorType.TokenRefreshingFailed +import com.nice.cxonechat.exceptions.RuntimeChatException +import com.nice.cxonechat.internal.model.ChannelConfiguration import com.nice.cxonechat.internal.model.Visitor import com.nice.cxonechat.internal.socket.WebSocketSpec import com.nice.cxonechat.server.ServerResponse +import com.nice.cxonechat.state.Configuration import com.nice.cxonechat.tool.nextString +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder import org.junit.Test -import org.mockito.ArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.inOrder -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue internal class ChatTest : AbstractChatTest() { + private var isAuthorizationEnabled = true + + override val config: ChannelConfiguration? + get() = super.config?.copy( + isAuthorizationEnabled = isAuthorizationEnabled + ) + @Test fun setDeviceToken_sendsExpectedMessage() { val token = nextString() - val inOrder = inOrder(service) - // The first empty token is set during instance creation - inOrder.verify(service, times(1)).createOrUpdateVisitor( - brandId = connection.brandId, - visitorId = connection.visitorId.toString(), - visitor = Visitor(connection) - ) + chat.setDeviceToken(token) - verify(storage, times(1)).deviceToken = token - inOrder.verify(service, times(1)).createOrUpdateVisitor( - brandId = connection.brandId, - visitorId = connection.visitorId.toString(), - visitor = Visitor(connection, token) - ) - inOrder.verifyNoMoreInteractions() + + verifyOrder { + service.getChannel(any(), any()) + service.createOrUpdateVisitor( + brandId = connection.brandId, + visitorId = connection.visitorId.toString(), + visitor = Visitor(connection) + ) + storage.deviceToken = token + service.createOrUpdateVisitor( + brandId = connection.brandId, + visitorId = connection.visitorId.toString(), + visitor = Visitor(connection, token) + ) + } + + confirmVerified(service) } @Test fun setDeviceToken_ignoresKnownToken() { - val inOrder = inOrder(service) - // The first empty token is set during instance creation - inOrder.verify(service, times(1)).createOrUpdateVisitor( - brandId = connection.brandId, - visitorId = connection.visitorId.toString(), - visitor = Visitor(connection) - ) val token = nextString() // Let's pretend that stored value equals to value which will be set - whenever(storage.deviceToken).thenReturn(token) + every { storage.deviceToken } returns token + chat.setDeviceToken(token) - verify(storage, times(0)).deviceToken = any() - inOrder.verifyNoMoreInteractions() + + verifyOrder { + service.getChannel(any(), any()) + service.createOrUpdateVisitor( + brandId = connection.brandId, + visitorId = connection.visitorId.toString(), + visitor = Visitor(connection) + ) + } + + verify(exactly = 0) { storage.deviceToken = any() } + + confirmVerified(service) } @Test fun signOut_clearsStorage() { + every { socket.close(any(), any()) } returns true + chat.signOut() - verify(storage).clearStorage() + + verify { storage.clearStorage() } } @Test fun signOut_closesConnection() { + every { socket.close(any(), any()) } returns true + chat.signOut() - verify(socket).close(WebSocketSpec.CLOSE_NORMAL_CODE, null) + + verify { socket.close(WebSocketSpec.CLOSE_NORMAL_CODE, null) } } @Test fun close_performsActions() { + every { socket.close(any(), any()) } returns true + chat.close() - socket.inOrder { - verify().close(WebSocketSpec.CLOSE_NORMAL_CODE, null) - Unit + + verify { + socket.close(WebSocketSpec.CLOSE_NORMAL_CODE, null) } } @@ -94,13 +126,82 @@ internal class ChatTest : AbstractChatTest() { fun build_authorization_updatesStorage_token() { val token = nextString() this serverResponds ServerResponse.TokenRefreshed(accessToken = token) - verify(storage).authToken = token + verify { storage.authToken = token } } @Test fun build_authorization_updatesStorage_tokenExpDate() { - val captor = ArgumentCaptor.forClass(Date::class.java) this serverResponds ServerResponse.TokenRefreshed() - verify(storage).authTokenExpDate = captor.capture() + verify { storage.authTokenExpDate = any() } + } + + @Test + fun build_authorization_notifies_about_token_refresh_failure() { + assertTrue(chatStateListener.onChatRuntimeExceptions.isEmpty()) + this serverResponds ServerResponse.ErrorResponse(TokenRefreshingFailed.value) + assertEquals(1, chatStateListener.onChatRuntimeExceptions.size) + assertTrue(chatStateListener.onChatRuntimeExceptions.last() is RuntimeChatException.AuthorizationError) + } + + @Test + fun build_authorization_notifies_about_consumer_reconnect_failure() { + assertTrue(chatStateListener.onChatRuntimeExceptions.isEmpty()) + this serverResponds ServerResponse.ErrorResponse(ConsumerReconnectionFailed.value) + assertEquals(1, chatStateListener.onChatRuntimeExceptions.size) + assertTrue(chatStateListener.onChatRuntimeExceptions.last() is RuntimeChatException.AuthorizationError) + } + + @Test + fun setUserName_updates_connection_in_no_auth_mode() { + isAuthorizationEnabled = false + prepare() + val firstName = "testFirstName" + val lastName = "testLastName" + assertNotEquals(firstName, connection.firstName) + assertNotEquals(lastName, connection.lastName) + chat.setUserName(firstName, lastName) + assertEquals(firstName, connection.firstName) + assertEquals(lastName, connection.lastName) + } + + @Test + fun setUserName_is_ignored_in_OAuth_mode() { + isAuthorizationEnabled = true + prepare() + val firstName = "testFirstName" + val lastName = "testLastName" + val originalFirstName = connection.firstName + val originalLastName = connection.lastName + assertNotEquals(firstName, originalFirstName) + assertNotEquals(lastName, originalLastName) + chat.setUserName(firstName, lastName) + assertEquals(originalFirstName, connection.firstName) + assertEquals(originalLastName, connection.lastName) + } + + @Test + fun chatMode_multithreaded() { + val mockConfiguration: Configuration = mockk { + every { hasMultipleThreadsPerEndUser } returns true + } + val mockChat: Chat = mockk { + every { configuration } returns mockConfiguration + every { chatMode } answers { callOriginal() } + } + + assertEquals(mockChat.chatMode, MULTI_THREAD) + } + + @Test + fun chatMode_singlethreaded() { + val mockConfiguration: Configuration = mockk { + every { hasMultipleThreadsPerEndUser } returns false + } + val mockChat: Chat = mockk { + every { configuration } returns mockConfiguration + every { chatMode } answers { callOriginal() } + } + + assertEquals(mockChat.chatMode, SINGLE_THREAD) } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt index 9ff3bc88..08270957 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadEventHandlerTest.kt @@ -4,8 +4,8 @@ import com.nice.cxonechat.event.thread.ChatThreadEvent import com.nice.cxonechat.model.makeChatThread import com.nice.cxonechat.server.ServerRequest import com.nice.cxonechat.thread.ChatThread +import io.mockk.every import org.junit.Test -import org.mockito.kotlin.whenever import java.util.Date internal class ChatThreadEventHandlerTest : AbstractChatTest() { @@ -31,7 +31,7 @@ internal class ChatThreadEventHandlerTest : AbstractChatTest() { @Test fun trigger_refreshesToken() { - whenever(storage.authTokenExpDate).thenReturn(Date()) + every { storage.authTokenExpDate } returns Date() assertSendTexts( ServerRequest.RefreshToken(connection), """{"field":10}""" diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadHandlerMessageReadByAgentTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadHandlerMessageReadByAgentTest.kt new file mode 100644 index 00000000..f9c50e8b --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadHandlerMessageReadByAgentTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2021-2023. 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 + +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 +import com.nice.cxonechat.model.makeChatThread +import com.nice.cxonechat.model.makeMessage +import com.nice.cxonechat.model.makeMessageModel +import com.nice.cxonechat.server.ServerResponse +import com.nice.cxonechat.thread.ChatThread +import org.junit.Test +import java.util.Date +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class ChatThreadHandlerMessageReadByAgentTest : AbstractChatTest() { + + private lateinit var message: MessageModel + private lateinit var messages: ChatThreadMessageHandler + private lateinit var chatThread: ChatThread + private lateinit var thread: ChatThreadHandler + + override fun prepare() { + super.prepare() + val threadId = UUID.randomUUID() + message = makeMessageModel(threadIdOnExternalPlatform = threadId) + chatThread = makeChatThread(messages = listOf(makeMessage(message)), id = threadId) + messages = chat.threads().thread(chatThread).messages() + thread = chat.threads().thread(chatThread) + } + + @Test + fun read_event_updates_message_in_thread() { + val expected = chatThread.asCopyable().copy( + messages = listOf( + makeMessage( + message.copy( + userStatistics = message.userStatistics.copy(readAt = Date(0)) + ) + ) + ) + ) + val actual = testCallback(::get) { + sendServerMessage(ServerResponse.MessageReadChanged(message)) + } + assertEquals(expected, actual.asCopyable().copy()) + } + + @Test + fun read_event_from_other_thread_is_ignored() { + val actual = testCallback(::get) { + sendServerMessage(ServerResponse.MessageReadChanged(makeMessageModel())) + } + assertNull(actual) + } + + @Test + fun read_event_without_message_is_ignored() { + val serializer = JsonSerializer<Noop> { _, _, _ -> + 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) + ) + ) + } + assertNull(actual) + } + + private fun get(listener: (ChatThread) -> Unit): Cancellable = + thread.get(listener = { listener(it) }) +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadHandlerTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadHandlerTest.kt index 1dbc5a9a..7754744f 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadHandlerTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadHandlerTest.kt @@ -24,9 +24,12 @@ import com.nice.cxonechat.internal.ChatWithParameters import com.nice.cxonechat.internal.copy.AgentCopyable.Companion.asCopyable import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable import com.nice.cxonechat.internal.model.ChannelConfiguration +import com.nice.cxonechat.internal.model.ChatThreadMutable +import com.nice.cxonechat.internal.model.ChatThreadMutable.Companion.asMutable import com.nice.cxonechat.internal.model.CustomFieldInternal import com.nice.cxonechat.internal.model.CustomFieldPolyType.Text import com.nice.cxonechat.internal.model.MessageModel +import com.nice.cxonechat.internal.model.network.EventCaseStatusChanged.CaseStatus.CLOSED import com.nice.cxonechat.message.Message import com.nice.cxonechat.model.makeAgent import com.nice.cxonechat.model.makeChatThread @@ -36,18 +39,22 @@ import com.nice.cxonechat.model.makeUserStatistics import com.nice.cxonechat.server.ServerRequest import com.nice.cxonechat.server.ServerResponse import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState +import com.nice.cxonechat.thread.ChatThreadState.Loaded +import com.nice.cxonechat.thread.ChatThreadState.Pending import com.nice.cxonechat.thread.CustomField import com.nice.cxonechat.tool.nextString import org.junit.Test import java.util.Date import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull internal class ChatThreadHandlerTest : AbstractChatTest() { - private lateinit var chatThread: ChatThread + private lateinit var chatThread: ChatThreadMutable private lateinit var thread: ChatThreadHandler private val customerCustomFields = listOf<CustomField>( @@ -117,7 +124,7 @@ internal class ChatThreadHandlerTest : AbstractChatTest() { fun get_updates_existing_messages_moreMassagesLoaded() { val id = chatThread.id val existingMessage = makeMessage(makeMessageModel(threadIdOnExternalPlatform = id)) - chatThread = chatThread.asCopyable().copy(messages = listOf(existingMessage)) + chatThread = chatThread.asCopyable().copy(messages = listOf(existingMessage)).asMutable() val messages = arrayOf( makeMessageModel(threadIdOnExternalPlatform = id), makeMessageModel(threadIdOnExternalPlatform = id, idOnExternalPlatform = existingMessage.id) @@ -140,7 +147,8 @@ internal class ChatThreadHandlerTest : AbstractChatTest() { val message = makeMessageModel(threadIdOnExternalPlatform = id) val expected = chatThread.asCopyable().copy( messages = listOfNotNull(message.toMessage()), - threadAgent = agent.toAgent() + threadAgent = agent.toAgent(), + threadState = Loaded, ) val actual = testCallback(::get) { sendServerMessage(ServerResponse.ThreadMetadataLoaded(agent, message)) @@ -247,10 +255,24 @@ internal class ChatThreadHandlerTest : AbstractChatTest() { ) ) } - assertEquals(expected, actual) + assertEquals<ChatThread>(expected, actual) assertEquals(customerCustomFields, chat.fields) } + @Test + fun recoverThreadBlockedInPending() { + for (state in ChatThreadState.entries) { + updateChatThread(chatThread.asCopyable().copy(threadState = state)) + if (state !== Pending) { + assertSendText(ServerRequest.RecoverThread(connection, chatThread)) { + thread.refresh() + } + } else { + assertSendsNothing { thread.refresh() } + } + } + } + @Test fun get_ignores_otherThanSelfThread_threadRecovered() { val actual = testCallback(::get) { @@ -341,9 +363,7 @@ internal class ChatThreadHandlerTest : AbstractChatTest() { val actual = testCallback(::get) { sendServerMessage(ServerResponse.TypingStarted(thread)) } - val threadAgent2 = threadAgent1.asCopyable().copy(isTyping = true) - val expected = thread.asCopyable().copy(threadAgent = threadAgent2) - assertEquals(expected, actual) + assertNull(actual) } @Test @@ -361,11 +381,10 @@ internal class ChatThreadHandlerTest : AbstractChatTest() { fun get_observes_agentTypingEnded() { // prime the returned thread and ensure the test doesn't return false positive get_observes_agentTypingStarted() - val expected = chatThread val actual = testCallback(::get) { - sendServerMessage(ServerResponse.TypingEnded(expected)) + sendServerMessage(ServerResponse.TypingEnded(chatThread)) } - assertEquals(expected, actual) + assertNull(actual) } @Test @@ -382,13 +401,30 @@ internal class ChatThreadHandlerTest : AbstractChatTest() { assertEquals(expected, actual) } + @Test + fun get_observes_case_closed() { + val expected = chatThread.asCopyable().copy( + canAddMoreMessages = false + ) + assertNotEquals<ChatThread>(chatThread, expected) + val actual = testCallback(::get) { + sendServerMessage(ServerResponse.CaseStatusChanged(chatThread.snapshot(), CLOSED)) + } + assertEquals(expected, actual) + } + // --- private fun get(listener: (ChatThread) -> Unit): Cancellable = thread.get(listener = { listener(it) }) private fun updateChatThread(updatedThread: ChatThread) { - chatThread = updatedThread - thread = chat.threads().thread(updatedThread) + val threadMutable = updatedThread.asMutable() // Handlers are memoized, therefore mutating the thread is required + if (::chatThread.isInitialized) { + chatThread.update(threadMutable) + } else { + chatThread = threadMutable + } + thread = chat.threads().thread(threadMutable) } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadMessageHandlerTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadMessageHandlerTest.kt index 4a9423fc..e6a8ff11 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadMessageHandlerTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadMessageHandlerTest.kt @@ -1,10 +1,29 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + @file:Suppress("FunctionMaxLength") package com.nice.cxonechat import android.util.Base64 +import android.webkit.MimeTypeMap import com.nice.cxonechat.ChatThreadMessageHandler.OnMessageTransferListener import com.nice.cxonechat.api.model.AttachmentUploadResponse +import com.nice.cxonechat.exceptions.InvalidParameterException +import com.nice.cxonechat.exceptions.InvalidStateException +import com.nice.cxonechat.exceptions.RuntimeChatException import com.nice.cxonechat.internal.model.AttachmentModel import com.nice.cxonechat.internal.model.AttachmentUploadModel import com.nice.cxonechat.message.ContentDescriptor @@ -16,19 +35,22 @@ import com.nice.cxonechat.server.ServerResponse import com.nice.cxonechat.thread.ChatThread import com.nice.cxonechat.tool.nextString import io.mockk.every +import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.verify +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever import retrofit2.Call import retrofit2.Response +import java.io.IOException import java.util.UUID +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.io.encoding.Base64 as KotlinBase64 internal class ChatThreadMessageHandlerTest : AbstractChatTest() { @@ -142,18 +164,34 @@ internal class ChatThreadMessageHandlerTest : AbstractChatTest() { } } + private fun mockMimeTypeMap() = mockk<MimeTypeMap> { + every { getExtensionFromMimeType(mimeType) } returns "wtf" + }.also { + mockkStatic(MimeTypeMap::class) + every { MimeTypeMap.getSingleton() } returns it + } + + @Test(expected = InvalidParameterException::class) + fun send_empty_message_throws() { + messages.send(OutboundMessage("")) + } + + @OptIn(ExperimentalEncodingApi::class) @Test fun send_attachments_sendsExpectedMessage() { - val expected = "content" + val bytes = Random.nextBytes(32) val postback = nextString() - val bytes = expected.toByteArray() + val expected = KotlinBase64.encode(bytes) + val attachments = listOf(AttachmentModel("url", "friendlyName", mimeType = mimeType)) + val call: Call<AttachmentUploadResponse?> = mockCall { AttachmentUploadResponse("url") } + val filename = "filename" + val upload = contentDescriptor(bytes, filename) + val mimeTypeMap = mockMimeTypeMap() - // since android.* classes aren't implemented for unit tests, mock out Base64 conversion - // to just return a fixed string - mockkStatic(Base64::class) - every { Base64.encodeToString(any(), any()) } returns expected + mockAndroidBase64() + + every { service.uploadFile(any(), any(), any()) } returns call - val attachments = listOf(AttachmentModel("url", "friendlyName", "application/wtf")) assertSendText( ServerRequest.SendMessage( connection = connection, @@ -164,22 +202,103 @@ internal class ChatThreadMessageHandlerTest : AbstractChatTest() { postback = postback ) ) { - val upload = ContentDescriptor(bytes, "application/wtf", "fileName", "friendlyName") - val call: Call<AttachmentUploadResponse?> = mock() - whenever(call.execute()).thenReturn(Response.success(AttachmentUploadResponse("url"))) - whenever(service.uploadFile(eq(AttachmentUploadModel(upload)), any(), any())).then { - assertEquals(connection.brandId.toString(), it.getArgument(1)) - assertEquals(connection.channelId, it.getArgument(2)) - call - } messages.send(OutboundMessage(listOf(upload), expected, postback)) } + val model = AttachmentUploadModel(upload) + verify { Base64.encodeToString(eq(bytes), eq(0)) + mimeTypeMap.getExtensionFromMimeType(mimeType) + service.uploadFile(model, connection.brandId.toString(), connection.channelId) } } + @OptIn(ExperimentalEncodingApi::class) + @Test + fun send_attachment_notifies_about_failure_in_response() { + val expected = nextString() + val filename = nextString() + val postback = nextString() + val bytes = KotlinBase64.decode(expected) + + mockAndroidBase64() + + assertSendText( + ServerRequest.SendMessage( + connection = connection, + thread = thread, + storage = storage, + message = expected, + attachments = emptyList(), + postback = postback + ) + ) { + val upload = contentDescriptor(bytes, filename) + every { service.uploadFile(any(), any(), any()) } returns mockk { + every { execute() } returns Response.error(418, "I am a teapot!".toResponseBody()) + } + messages.send(OutboundMessage(listOf(upload), expected, postback)) + } + val exception = chatStateListener.onChatRuntimeExceptions.last() + assertTrue( + exception is RuntimeChatException.AttachmentUploadError, + "Expected exception of type ${RuntimeChatException.AttachmentUploadError::class.simpleName} but was " + + "${exception::class.simpleName}" + ) + assertEquals(filename, exception.attachmentName) + assertTrue( + exception.cause is InvalidStateException, + "Expected exception cause of type ${InvalidStateException::class.simpleName} but was " + + "${exception.cause?.javaClass?.kotlin?.simpleName}" + ) + } + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun send_attachment_notifies_about_failure_in_network_call() { + val expected = nextString() + val filename = nextString() + val postback = nextString() + val bytes = KotlinBase64.decode(expected) + + mockAndroidBase64() + + val ioException = IOException("This is a test") + assertSendText( + ServerRequest.SendMessage( + connection = connection, + thread = thread, + storage = storage, + message = expected, + attachments = emptyList(), + postback = postback + ) + ) { + val upload = contentDescriptor(bytes, filename) + every { service.uploadFile(any(), any(), any()) } returns mockk { + every { execute() } throws ioException + } + messages.send(OutboundMessage(listOf(upload), expected, postback)) + } + val exception = chatStateListener.onChatRuntimeExceptions.last() + assertTrue( + exception is RuntimeChatException.AttachmentUploadError, + "Expected exception of type ${RuntimeChatException.AttachmentUploadError::class.simpleName} but was " + + "${exception::class.simpleName}" + ) + assertEquals(filename, exception.attachmentName) + assertEquals(ioException, exception.cause) + } + + @OptIn(ExperimentalEncodingApi::class) + private fun mockAndroidBase64() { + // since android.* classes aren't implemented for unit tests, mock out Base64 conversion + // to just return a fixed string + mockkStatic(Base64::class) + every { Base64.encodeToString(any(), any()) } answers { KotlinBase64.encode(arg<ByteArray>(0)) } + } + @Test fun send_text_respondsWithCallback() { val result = testCallback<UUID> { trigger -> @@ -210,4 +329,11 @@ internal class ChatThreadMessageHandlerTest : AbstractChatTest() { } assertSame(processedId, result) } + + companion object { + private const val mimeType = "application/wtf" + + private fun contentDescriptor(bytes: ByteArray, filename: String) = + ContentDescriptor(bytes, "application/wtf", filename, "friendlyName") + } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerPreChatSurveyTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerPreChatSurveyTest.kt index 5aaaa0e0..9b37637e 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerPreChatSurveyTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerPreChatSurveyTest.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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. - */ - @file:Suppress("FunctionMaxLength") package com.nice.cxonechat @@ -265,6 +250,7 @@ internal class ChatThreadsHandlerPreChatSurveyTest : AbstractChatTest() { return connection to ChatBuilder(entrails, factory) } + @Suppress("DEPRECATED", "DEPRECATION") private fun build( builder: ChatBuilder = prepareBuilder().second, body: ChatBuilder.() -> ChatBuilder = { this }, diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerSingleThreadTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerSingleThreadTest.kt index 01309bf9..b3c20416 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerSingleThreadTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerSingleThreadTest.kt @@ -1,7 +1,23 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + @file:Suppress("FunctionMaxLength") package com.nice.cxonechat +import android.annotation.SuppressLint import com.nice.cxonechat.exceptions.MissingThreadListFetchException import com.nice.cxonechat.exceptions.UnsupportedChannelConfigException import com.nice.cxonechat.internal.model.ChannelConfiguration @@ -71,6 +87,7 @@ internal class ChatThreadsHandlerSingleThreadTest : AbstractChatTest() { chat.threads().create(nextStringMap()) } + @SuppressLint("CheckResult") @Test fun create_permitsSingularThread() { with(chat.threads()) { @@ -80,6 +97,7 @@ internal class ChatThreadsHandlerSingleThreadTest : AbstractChatTest() { } } + @SuppressLint("CheckResult") @Test fun create_withCustomFields_permitsSingularThread() { with(chat.threads()) { diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerTest.kt index ca69acad..4e535964 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/ChatThreadsHandlerTest.kt @@ -2,20 +2,19 @@ package com.nice.cxonechat +import com.nice.cxonechat.FakeChatStateListener.ChatStateConnection.READY import com.nice.cxonechat.internal.model.ChannelConfiguration -import com.nice.cxonechat.internal.model.CustomFieldInternal import com.nice.cxonechat.internal.model.CustomFieldPolyType.Text +import com.nice.cxonechat.internal.model.network.EventCaseStatusChanged.CaseStatus.CLOSED import com.nice.cxonechat.model.makeChatThread +import com.nice.cxonechat.model.makeMessageModel import com.nice.cxonechat.server.ServerRequest import com.nice.cxonechat.server.ServerResponse import com.nice.cxonechat.thread.ChatThread -import com.nice.cxonechat.thread.CustomField -import com.nice.cxonechat.tool.SocketFactoryMock +import com.nice.cxonechat.thread.ChatThreadState.Received import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.whenever import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull internal class ChatThreadsHandlerTest : AbstractChatTest() { @@ -48,11 +47,27 @@ internal class ChatThreadsHandlerTest : AbstractChatTest() { @Test fun threads_notifies_withInitialList() { - val expected = List(2) { makeChatThread() } - val actual = testCallback(::threads) { - sendServerMessage(ServerResponse.ThreadListFetched(expected)) + val expected = List(2) { makeChatThread(threadState = Received) } + + // verify that the metadata is loaded when the list is received + assertSendTexts( + ServerRequest.FetchThreadList(connection), + ServerRequest.LoadThreadMetadata(connection, expected[0]), + ServerRequest.LoadThreadMetadata(connection, expected[1]) + ) { + // Multithread threads should start in READY state. + assertEquals(READY, chatStateListener.connection) + val actual = testCallback(::threads) { + sendServerMessage(ServerResponse.ThreadListFetched(expected)) + sendServerMessage( + ServerResponse.ThreadMetadataLoaded(message = makeMessageModel(threadIdOnExternalPlatform = expected[0].id)) + ) + sendServerMessage( + ServerResponse.ThreadMetadataLoaded(message = makeMessageModel(threadIdOnExternalPlatform = expected[1].id)) + ) + } + assertEquals(expected, actual) } - assertEquals(expected, actual) } @Test @@ -70,51 +85,19 @@ internal class ChatThreadsHandlerTest : AbstractChatTest() { } @Test - fun create_sends_simpleWelcomeMessage_toThread() { - val expected = "Welcome, how was your day?" - assertSendsWelcomeMessageToThread(expected, expected) - } - - @Test - fun create_sendsComplexWelcomeMessage_toThread() { - val message = "Welcome {{customer.firstName|stranger}}, how was your {{customer.customFields.testField|day}}?" - val expected = "Welcome ${SocketFactoryMock.firstName}, how was your testValue?" - assertSendsWelcomeMessageToThread(message, expected) - } - - @Test - fun create_withCustomParameters_sendsComplexWelcomeMessage_toThread() { - val message = "Welcome {{customer.firstName|stranger}}, " + - "how was your {{customer.customFields.testField|day}} " + - "{{contact.customFields.testField2|dear customer}}?" + - "{{fallbackMessage|This unit test has failed.}}" - val expected = "Welcome ${SocketFactoryMock.firstName}, how was your testValue testValue2?" - val contactCustomFields = mapOf("testField2" to "testValue2") - assertSendsWelcomeMessageToThread(message, expected, contactCustomFields.map(::CustomFieldInternal)) { - chat.threads().create(contactCustomFields) + fun threads_notifies_caseClosed() { + val initial = List(2) { makeChatThread(threadState = Received) } + val expected = initial.toMutableList().also { + it[0] = it[0].copy(canAddMoreMessages = false) } + val actual = testCallback(::threads) { + sendServerMessage(ServerResponse.ThreadListFetched(initial)) + sendServerMessage(ServerResponse.CaseStatusChanged(expected[0], CLOSED)) + } + assertNotEquals(expected, initial) + assertEquals(expected, actual) } fun threads(listener: (List<ChatThread>) -> Unit): Cancellable = threads.threads(listener = { listener(it) }) - - private fun assertSendsWelcomeMessageToThread( - message: String, - expected: String, - contactCustomFields: List<CustomField> = emptyList(), - create: () -> ChatThreadHandler = { chat.threads().create() } - ) { - var welcomeMessage = "" - doAnswer { welcomeMessage = it.getArgument(0) }.whenever(storage).welcomeMessage = any() - whenever(storage.welcomeMessage).thenAnswer { welcomeMessage } - val customerCustomFields = mapOf("testField" to "testValue") - this serverResponds ServerResponse.WelcomeMessage(message, customerCustomFields) - val thread = makeChatThread(id = TestUUIDValue, fields = contactCustomFields) - assertSendText( - expected = ServerRequest.SendOutbound(connection, thread, storage, expected), - replaceDate = true, - ) { - create() - } - } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/WelcomeMessageTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/WelcomeMessageTest.kt new file mode 100644 index 00000000..55d3aae1 --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/WelcomeMessageTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2021-2023. 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 + +import com.nice.cxonechat.internal.copy.ChatThreadCopyable.Companion.asCopyable +import com.nice.cxonechat.internal.model.ChannelConfiguration +import com.nice.cxonechat.internal.model.CustomFieldInternal +import com.nice.cxonechat.internal.model.CustomFieldPolyType.Text +import com.nice.cxonechat.message.Message +import com.nice.cxonechat.model.makeChatThread +import com.nice.cxonechat.server.ServerRequest +import com.nice.cxonechat.server.ServerResponse +import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.CustomField +import com.nice.cxonechat.tool.SocketFactoryMock +import com.nice.cxonechat.tool.nextString +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.Test +import java.util.Date +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +internal class WelcomeMessageTest : AbstractChatTest() { + + private val customerCustomFields = listOf<CustomField>( + CustomFieldInternal("1", nextString(), Date(0)), + CustomFieldInternal("2", nextString(), Date(0)) + ) + private val contactCustomFields = listOf<CustomField>( + CustomFieldInternal("1", nextString(), Date(0)), + CustomFieldInternal("2", nextString(), Date(0)) + ) + + private val enteredCustomerCustomFields = mapOf("testField" to "testValue") + + override val config: ChannelConfiguration + get() { + return requireNotNull(super.config).copy( + contactCustomFields = contactCustomFields.map { + Text(it.id, it.value) + }, + customerCustomFields = customerCustomFields.map { + Text(it.id, it.value) + } + ) + } + + @Test + fun get_contains_welcome_message() { + val welcomeMessage = "This is a simple welcome message" + setupWelcomeMessage(welcomeMessage) + assertThreadContainsOnlyThisMessage(welcomeMessage) + } + + @Test + fun get_is_notified_about_welcome_message() { + val welcomeMessage = "This is a different welcome message" + setupWelcomeMessage(welcomeMessage) + val handler = chat.threads().create(contactCustomFields.asMap()) + val latch = CountDownLatch(1) + val id = UUID.randomUUID() + var thread: ChatThread = makeChatThread(id) + assert(thread.messages.isEmpty()) + val cancellable = handler.get { + thread = it + latch.countDown() + } + latch.await(100, TimeUnit.MILLISECONDS) + cancellable.cancel() + assertNotEquals(id, thread.id) + val messages = thread.messages + assert(messages.isNotEmpty()) + assertEquals(welcomeMessage, (messages[0] as Message.Text).text) + confirmVerified(socket) + } + + @Test + fun send_message_sends_welcome_message_first() { + val welcomeMessage = nextString() + setupWelcomeMessage(welcomeMessage) + val handler = chat.threads().create(contactCustomFields.asMap()) + val firstUserMessage = "Message from user" + val thread = handler.get().asCopyable().copy(id = TestUUIDValue) + assertSendTexts( + expected = arrayOf( + ServerRequest.SendOutbound(connection, thread, storage, welcomeMessage), + ServerRequest.SendMessage(connection, thread, storage, firstUserMessage, enteredCustomerCustomFields), + ), + replaceDate = true, + ) { + handler.messages().send(firstUserMessage) + } + verifyOrder { + socket.send(text = match<String> { it.contains(welcomeMessage) }) + socket.send(text = match<String> { it.contains(firstUserMessage) }) + } + confirmVerified(socket) + } + + @Test + fun welcome_message_is_not_added_to_existing_thread() { + val welcomeMessage = "This is a welcome message" + setupWelcomeMessage(welcomeMessage) + val handler1 = chat.threads().create() + val threadFromHandler1 = handler1.get() + val handler2 = chat.threads().thread(threadFromHandler1) + assert(threadFromHandler1.messages.size == 1) + assert(threadFromHandler1.messages.size == handler2.get().messages.size) + confirmVerified(socket) + } + + @Test + fun complex_message_is_added_to_thread() { + val message = "Welcome {{customer.firstName|stranger}}, how was your {{customer.customFields.testField|day}}?" + val expected = "Welcome ${SocketFactoryMock.firstName}, how was your testValue?" + setupWelcomeMessage(message) + assertThreadContainsOnlyThisMessage(expected) + } + + @Test + fun create_withCustomParameters_sendsComplexWelcomeMessage_toThread() { + val message = "Welcome {{customer.firstName|stranger}}, " + + "how was your {{customer.customFields.testField|day}} " + + "{{contact.customFields.testField2|'failed test'}}?" + + "{{fallbackMessage|This unit test has failed.}}" + val expected = "Welcome ${SocketFactoryMock.firstName}, how was your testValue testValue2?" + val contactCustomFields = mapOf("testField2" to "testValue2") + setupWelcomeMessage(message) + assertThreadContainsOnlyThisMessage(expected, contactCustomFields) + } + + private fun setupWelcomeMessage( + message: String, + customerCustomFields: Map<String, String> = enteredCustomerCustomFields, + ) { + setupWelcomeMessagePersistence() + this serverResponds ServerResponse.WelcomeMessage(message, customerCustomFields) + + verify(exactly = 1) { + socket.send(match<String> { it.contains("ReconnectConsumer") }) + } + } + + private fun setupWelcomeMessagePersistence() { + var backing = "" + + every { storage.welcomeMessage = any() } answers { backing = arg(0) } + every { storage.welcomeMessage } answers { backing } + } + + private fun assertThreadContainsOnlyThisMessage(expected: String, customerCustomFields: Map<String, String> = emptyMap()) { + val handler = chat.threads().create(contactCustomFields.asMap() + customerCustomFields) + val messages = handler.get().messages + assertEquals(1, messages.size) + val message = messages[0] + assertEquals(expected, (message as Message.Text).text) + confirmVerified(socket) + } +} + +private fun List<CustomField>.asMap() = associate { it.id to it.value } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/api/RemoteServiceTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/api/RemoteServiceTest.kt index 347c4fa3..9de33293 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/api/RemoteServiceTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/api/RemoteServiceTest.kt @@ -128,7 +128,7 @@ internal class RemoteServiceTest { } val client = builder.build() - val upload = AttachmentUploadModel("content", "mime", "name") + val upload = AttachmentUploadModel("content", "mime", "name.txt") client.uploadFile(upload, "0", "channelId").execute() client.uploadFile(upload, "0", "channelId").execute() diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuardTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuardTest.kt index 839fcf15..3aa23edf 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuardTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatEventHandlerVisitGuardTest.kt @@ -49,7 +49,7 @@ class ChatEventHandlerVisitGuardTest { @InjectMockKs private lateinit var guard: ChatEventHandlerVisitGuard - private val kNow = Date() + private val now = Date() private val Date.expires: Date get() = this + 30.minutes @@ -66,8 +66,8 @@ class ChatEventHandlerVisitGuardTest { MockKAnnotations.init(this) every { chat.events() } returns guard - every { origin.trigger(any(), any()) } answers { - (it.invocation.args[1] as? OnEventSentListener)?.onSent() + every { origin.trigger(any(), any(), any()) } answers { + arg<OnEventSentListener?>(1)?.onSent() Unit } every { chat.storage } returns storage @@ -75,7 +75,7 @@ class ChatEventHandlerVisitGuardTest { @Test fun `no visit generates new visit`() { - val event = PageViewEvent(title, url, kNow) + val event = PageViewEvent(title, url, now) // return no visit in place yet every { storage.visitDetails } returns null @@ -89,26 +89,26 @@ class ChatEventHandlerVisitGuardTest { verify(ordering = ORDERED) { // visit details should be updated with matching visit and updated time storage.visitDetails = match { - it.validUntil == kNow.expires + it.validUntil == now.expires } // visit event should be generated with a current time origin.trigger( match { - (it as? VisitEvent)?.date == kNow + (it as? VisitEvent)?.date == now } ) // and the page view event should be passed on to the origin - origin.trigger(event, any()) + origin.trigger(event, any(), any()) } } @Test fun `stale visit id generates new visit`() { - val event = PageViewEvent(title, url, kNow) + val event = PageViewEvent(title, url, now) val visitID = UUID.randomUUID() // return a stale visit details, it expired 1 millisecond ago - every { storage.visitDetails } returns VisitDetails(visitID, kNow - 1.milliseconds) + every { storage.visitDetails } returns VisitDetails(visitID, now - 1.milliseconds) every { storage.visitDetails = any() } returns Unit @@ -119,25 +119,25 @@ class ChatEventHandlerVisitGuardTest { verify(ordering = ORDERED) { // visit details should be updated with matching visit and updated time storage.visitDetails = match { - it.validUntil == kNow.expires + it.validUntil == now.expires } // visit event should be generated with a current time origin.trigger( match { - (it as? VisitEvent)?.date == kNow + (it as? VisitEvent)?.date == now } ) // and the original event should be passed on to the origin - origin.trigger(event, any()) + origin.trigger(event, any(), any()) } } @Test fun `fresh visit updates visit id`() { - val event = PageViewEvent(title, url, kNow) + val event = PageViewEvent(title, url, now) val visitID = UUID.randomUUID() - every { storage.visitDetails } returns VisitDetails(visitID, kNow + 1.milliseconds) + every { storage.visitDetails } returns VisitDetails(visitID, now + 1.milliseconds) every { storage.visitDetails = any() } returns Unit @@ -147,9 +147,9 @@ class ChatEventHandlerVisitGuardTest { verify(ordering = ORDERED) { // visit details should be updated with original visit and updated time - storage.visitDetails = VisitDetails(visitID, kNow.expires) + storage.visitDetails = VisitDetails(visitID, now.expires) // and the original event should be passed on to the origin - origin.trigger(event, any()) + origin.trigger(event, any(), any()) } } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt index e9612a35..9f53164b 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/ChatThreadEventHandlerArchivalTest.kt @@ -28,6 +28,7 @@ import com.nice.cxonechat.internal.model.network.EventThreadUpdated import com.nice.cxonechat.internal.model.network.Postback import com.nice.cxonechat.internal.serializer.Default import com.nice.cxonechat.internal.socket.ProxyWebSocketListener +import com.nice.cxonechat.thread.ChatThreadState.Received import io.mockk.Ordering.ORDERED import io.mockk.every import io.mockk.mockk @@ -41,7 +42,7 @@ import kotlin.test.assertTrue class ChatThreadEventHandlerArchivalTest { private val threadId = UUID.randomUUID() - private val thread = ChatThreadMutable.from(ChatThreadInternal(threadId)) + private val thread = ChatThreadMutable.from(ChatThreadInternal(threadId, threadState = Received)) private lateinit var socketListener: ProxyWebSocketListener private lateinit var origin: ChatThreadEventHandler private lateinit var chat: ChatWithParameters @@ -55,8 +56,8 @@ class ChatThreadEventHandlerArchivalTest { every { onMessage(any(), any<String>()) } returns Unit } origin = mockk { - every { trigger(any(), any()) } answers { - (it.invocation.args[1] as? OnEventSentListener)?.onSent() + every { trigger(any(), any(), any()) } answers { + arg<OnEventSentListener?>(1)?.onSent() } } chat = mockk { @@ -73,7 +74,7 @@ class ChatThreadEventHandlerArchivalTest { handler.typingStart(onSent) verify { - origin.trigger(any<TypingStartEvent>(), any()) + origin.trigger(any<TypingStartEvent>(), any(), any()) onSent.onSent() } @@ -100,7 +101,7 @@ class ChatThreadEventHandlerArchivalTest { ).let<EventThreadUpdated, String>(Default.serializer::toJson) verify(ordering = ORDERED) { - origin.trigger(any<ArchiveThreadEvent>(), any()) + origin.trigger(any<ArchiveThreadEvent>(), any(), any()) socketListener.onMessage(socket, expect) onSent.onSent() } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/MessageModelTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/MessageModelTest.kt new file mode 100644 index 00000000..e65aa97a --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/MessageModelTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021-2023. 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.internal.model.AgentModel +import com.nice.cxonechat.internal.model.CustomerIdentityModel +import com.nice.cxonechat.internal.model.MessageDirectionModel +import com.nice.cxonechat.internal.model.MessageDirectionModel.ToAgent +import com.nice.cxonechat.internal.model.MessageDirectionModel.ToClient +import com.nice.cxonechat.internal.model.MessageModel +import io.mockk.mockk +import org.junit.Test +import java.util.Date +import java.util.UUID +import kotlin.test.assertEquals + +internal class MessageModelTest { + private fun messageModel(direction: MessageDirectionModel) = MessageModel( + idOnExternalPlatform = UUID.randomUUID(), + threadIdOnExternalPlatform = UUID.randomUUID(), + messageContent = mockk(), + createdAt = Date(), + attachments = listOf(), + direction = direction, + userStatistics = mockk(), + authorUser = AgentModel( + id = 1, + inContactId = UUID.randomUUID(), + emailAddress = null, + firstName = "Agent", + surname = "Name", + nickname = null, + isBotUser = false, + isSurveyUser = false, + imageUrl = "http://doesnt.exist", + ), + authorEndUserIdentity = CustomerIdentityModel( + idOnExternalPlatform = UUID.randomUUID().toString(), + firstName = "Customer", + lastName = "Name", + imageUrl = null, + ) + ) + + @Test + fun testClientAuthor() { + assertEquals( + messageModel(direction = ToAgent).author?.firstName, + "Customer" + ) + assertEquals( + messageModel(direction = ToClient).author?.firstName, + "Agent" + ) + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/model/AttachmentUploadModelTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/model/AttachmentUploadModelTest.kt new file mode 100644 index 00000000..79b0c1b5 --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/model/AttachmentUploadModelTest.kt @@ -0,0 +1,80 @@ +package com.nice.cxonechat.internal.model + +import android.util.Base64 +import android.webkit.MimeTypeMap +import com.nice.cxonechat.message.ContentDescriptor +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.junit.Before +import org.junit.Test +import kotlin.io.encoding.Base64.Default.encode +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random +import kotlin.test.assertEquals + +class AttachmentUploadModelTest { + val mimeTypeMap: MimeTypeMap = mockk<MimeTypeMap> { + every { getExtensionFromMimeType(mimeType) } returns extension + }.also { + mockkStatic(MimeTypeMap::class) + every { MimeTypeMap.getSingleton() } returns it + } + + @OptIn(ExperimentalEncodingApi::class) + @Before + fun setup() { + // since android.* classes aren't implemented for unit tests, mock out Base64 conversion + // to just return a fixed string + mockkStatic(Base64::class) + every { Base64.encodeToString(any(), any()) } answers { encode(arg<ByteArray>(0)) } + } + + @Test + fun constructorAppliesExtension() { + val model = AttachmentUploadModel(content, mimeType, filename) + + assertEquals(content, model.content) + assertEquals(mimeType, model.mimeType) + assertEquals("filename.$extension", model.fileName) + } + + @Test + fun constructorKeepsExtension() { + val model = AttachmentUploadModel(content, mimeType, "filename.txt") + + assertEquals(content, model.content) + assertEquals(mimeType, model.mimeType) + assertEquals("filename.txt", model.fileName) + } + + @Test + fun contentDescriptorConstructorAppliesDefaultFilename() { + val contentDescriptor = ContentDescriptor(bytes, mimeType, filename, null) + val model = AttachmentUploadModel(contentDescriptor) + + assertEquals(content, model.content) + assertEquals(mimeType, model.mimeType) + assertEquals("filename.$extension", model.fileName) + } + + @Test + fun contentDescriptorConstructorAppliesKeepsFriendlyName() { + val contentDescriptor = ContentDescriptor(bytes, mimeType, filename, "friendly") + val model = AttachmentUploadModel(contentDescriptor) + + assertEquals(content, model.content) + assertEquals(mimeType, model.mimeType) + assertEquals("friendly.$extension", model.fileName) + } + + companion object { + const val filename = "filename" + const val mimeType = "application/wtf" + const val extension = "wtf" + val bytes: ByteArray by lazy { Random.nextBytes(32) } + + @OptIn(ExperimentalEncodingApi::class) + val content: String by lazy { encode(bytes) } + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/model/PluginElementTextTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/model/PluginElementTextTest.kt new file mode 100644 index 00000000..04b61260 --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/internal/model/PluginElementTextTest.kt @@ -0,0 +1,40 @@ +package com.nice.cxonechat.internal.model + +import com.nice.cxonechat.internal.model.network.MessagePolyElement.Text +import com.nice.cxonechat.message.TextFormat +import com.nice.cxonechat.message.TextFormat.Html +import com.nice.cxonechat.message.TextFormat.Markdown +import com.nice.cxonechat.message.TextFormat.Plain +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test +import kotlin.test.assertEquals + +class PluginElementTextTest { + private fun pluginElement(text: String, format: TextFormat) = PluginElementText( + Text(text, format.mimeType) + ) + + @Test + fun getFormat() { + assertEquals(Plain, pluginElement("text", Plain).format) + assertEquals(Markdown, pluginElement("text", Markdown).format) + assertEquals(Html, pluginElement("text", Html).format) + } + + @Suppress("DEPRECATION") + @Test + fun isMarkdown() { + assertFalse(pluginElement("text", Plain).isMarkdown) + assertTrue(pluginElement("text", Markdown).isMarkdown) + assertFalse(pluginElement("text", Html).isMarkdown) + } + + @Suppress("DEPRECATION") + @Test + fun isHtml() { + assertFalse(pluginElement("text", Plain).isHtml) + assertFalse(pluginElement("text", Markdown).isHtml) + assertTrue(pluginElement("text", Html).isHtml) + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LevelTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LevelTest.kt deleted file mode 100644 index 82c4a954..00000000 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LevelTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.nice.cxonechat.log - -import org.junit.Test -import kotlin.test.assertEquals - -internal class LevelTest { - - @Test - fun severe_hasLevel1000() { - val level = Level.Severe - assertEquals(1000, level.intValue) - } - - @Test - fun warning_hasLevel900() { - val level = Level.Warning - assertEquals(900, level.intValue) - } - - @Test - fun info_hasLevel800() { - val level = Level.Info - assertEquals(800, level.intValue) - } - - @Test - fun config_hasLevel700() { - val level = Level.Config - assertEquals(700, level.intValue) - } - - @Test - fun fine_hasLevel500() { - val level = Level.Fine - assertEquals(500, level.intValue) - } - - @Test - fun finer_hasLevel400() { - val level = Level.Finer - assertEquals(400, level.intValue) - } - - @Test - fun finest_hasLevel300() { - val level = Level.Finest - assertEquals(300, level.intValue) - } - - @Test - fun all_hasLevelMinValue() { - val level = Level.All - assertEquals(Int.MIN_VALUE, level.intValue) - } - - @Test - fun custom_hasLevelUnmodified() { - val level = Level.Custom(154) - assertEquals(154, level.intValue) - } - - @Test - fun compareTo_returnsValidInteger() { - assert(Level.Custom(0) < Level.Custom(400)) { - "compareTo returned invalid value for comparison. It's expected that 0 < 400" - } - } -} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LoggerNoopTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LoggerNoopTest.kt index c3d0a35b..d5e5e4df 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LoggerNoopTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LoggerNoopTest.kt @@ -1,10 +1,10 @@ package com.nice.cxonechat.log +import io.mockk.confirmVerified +import io.mockk.mockk import org.junit.After import org.junit.Before import org.junit.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.verifyZeroInteractions import java.io.PrintStream internal class LoggerNoopTest { @@ -13,7 +13,7 @@ internal class LoggerNoopTest { @Before fun prepare() { - out = mock() + out = mockk() System.setOut(out) } @@ -25,6 +25,6 @@ internal class LoggerNoopTest { @Test fun log_hasNoInteractions() { LoggerNoop.log(Level.Info, "") - verifyZeroInteractions(out) + confirmVerified(out) } } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LoggerTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LoggerTest.kt deleted file mode 100644 index be3a15e4..00000000 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/log/LoggerTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.nice.cxonechat.log - -import com.nice.cxonechat.tool.nextString -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify - -internal class LoggerTest { - - private lateinit var logger: Logger - - @Before - fun prepare() { - logger = mock() - } - - @Test - fun finest_withoutThrowable() { - val message = nextString() - logger.finest(message) - verify(logger).log(Level.Finest, message, null) - } - - @Test - fun finest_withThrowable() { - val message = nextString() - val throwable = RuntimeException() - logger.finest(message, throwable) - verify(logger).log(Level.Finest, message, throwable) - } - - @Test - fun finer_withoutThrowable() { - val message = nextString() - logger.finer(message) - verify(logger).log(Level.Finer, message, null) - } - - @Test - fun finer_withThrowable() { - val message = nextString() - val throwable = RuntimeException() - logger.finer(message, throwable) - verify(logger).log(Level.Finer, message, throwable) - } - - @Test - fun fine_withoutThrowable() { - val message = nextString() - logger.fine(message) - verify(logger).log(Level.Fine, message, null) - } - - @Test - fun fine_withThrowable() { - val message = nextString() - val throwable = RuntimeException() - logger.fine(message, throwable) - verify(logger).log(Level.Fine, message, throwable) - } - - @Test - fun info_withoutThrowable() { - val message = nextString() - logger.info(message) - verify(logger).log(Level.Info, message, null) - } - - @Test - fun info_withThrowable() { - val message = nextString() - val throwable = RuntimeException() - logger.info(message, throwable) - verify(logger).log(Level.Info, message, throwable) - } - - @Test - fun warning_withoutThrowable() { - val message = nextString() - logger.warning(message) - verify(logger).log(Level.Warning, message, null) - } - - @Test - fun warning_withThrowable() { - val message = nextString() - val throwable = RuntimeException() - logger.warning(message, throwable) - verify(logger).log(Level.Warning, message, throwable) - } - - @Test - fun severe_withoutThrowable() { - val message = nextString() - logger.severe(message) - verify(logger).log(Level.Severe, message, null) - } - - @Test - fun severe_withThrowable() { - val message = nextString() - val throwable = RuntimeException() - logger.severe(message, throwable) - verify(logger).log(Level.Severe, message, throwable) - } -} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/message/MessageMetadataStatusTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/message/MessageMetadataStatusTest.kt new file mode 100644 index 00000000..a951a7a4 --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/message/MessageMetadataStatusTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2021-2023. 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.message + +import com.nice.cxonechat.model.makeMessage +import com.nice.cxonechat.model.makeMessageModel +import com.nice.cxonechat.model.makeUserStatistics +import org.junit.Test +import java.util.Date +import kotlin.test.assertEquals + +internal class MessageMetadataStatusTest { + + @Test + fun message_is_sent_by_default() { + val defaultMessage: Message = makeMessage() + assertEquals(MessageStatus.SENT, defaultMessage.metadata.status) + } + + @Test + fun message_is_reported_as_seen() { + val messageSeen: Message = makeMessage( + model = makeMessageModel( + userStatistics = makeUserStatistics( + seenAt = Date() + ) + ) + ) + assertEquals(MessageStatus.SEEN, messageSeen.metadata.status) + } + + @Test + fun message_is_reported_as_read() { + val messageSeenAndRead: Message = makeMessage( + model = makeMessageModel( + userStatistics = makeUserStatistics( + seenAt = Date(), + readAt = Date() + ) + ) + ) + assertEquals(MessageStatus.READ, messageSeenAndRead.metadata.status) + val messageRead: Message = makeMessage( + model = makeMessageModel( + userStatistics = makeUserStatistics( + readAt = Date() + ) + ) + ) + assertEquals(MessageStatus.READ, messageRead.metadata.status) + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/message/TextFormatTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/message/TextFormatTest.kt new file mode 100644 index 00000000..9002f2a2 --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/message/TextFormatTest.kt @@ -0,0 +1,33 @@ +package com.nice.cxonechat.message + +import com.nice.cxonechat.message.TextFormat.Html +import com.nice.cxonechat.message.TextFormat.Markdown +import com.nice.cxonechat.message.TextFormat.Plain +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class TextFormatTest { + @Test + fun testConstruction() { + assertEquals(Html, TextFormat.from("text/html")) + assertEquals(Markdown, TextFormat.from("text/markdown")) + assertEquals(Plain, TextFormat.from("text/plain")) + assertEquals(Plain, TextFormat.from("text/rtf")) + } + + @Test + fun testIsMarkdown() { + assertTrue(Markdown.isMarkdown) + assertFalse(Html.isMarkdown) + assertFalse(Plain.isMarkdown) + } + + @Test + fun testIsHtml() { + assertFalse(Markdown.isHtml) + assertTrue(Html.isHtml) + assertFalse(Plain.isHtml) + } +} diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/ChatThread.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/ChatThread.kt index 13e30c7a..542c2204 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/ChatThread.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/ChatThread.kt @@ -3,6 +3,8 @@ package com.nice.cxonechat.model import com.nice.cxonechat.internal.model.ChatThreadInternal import com.nice.cxonechat.message.Message import com.nice.cxonechat.thread.Agent +import com.nice.cxonechat.thread.ChatThreadState +import com.nice.cxonechat.thread.ChatThreadState.Ready import com.nice.cxonechat.thread.CustomField import java.util.UUID @@ -15,6 +17,7 @@ internal fun makeChatThread( canAddMoreMessages: Boolean = true, scrollToken: String = "", fields: List<CustomField> = emptyList(), + threadState: ChatThreadState = Ready, ) = ChatThreadInternal( id = id, threadName = threadName, @@ -23,4 +26,5 @@ internal fun makeChatThread( canAddMoreMessages = canAddMoreMessages, scrollToken = scrollToken, fields = fields, + threadState = threadState, ) diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/Connection.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/Connection.kt index ffe695ca..26b00b84 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/Connection.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/Connection.kt @@ -13,8 +13,8 @@ internal fun makeConnection( channelId: String = nextString(), firstName: String = nextString(), lastName: String = nextString(), - customerId: UUID = UUID.randomUUID(), - environment: Environment = CXOneEnvironment.values().random().value, + customerId: String = UUID.randomUUID().toString(), + environment: Environment = CXOneEnvironment.entries.random().value, visitorId: UUID = UUID.randomUUID(), ) = ConnectionInternal( brandId = brandId, diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/CustomerAuthorizedEvent.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/CustomerAuthorizedEvent.kt index 27dea8a5..3d71e1cc 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/CustomerAuthorizedEvent.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/CustomerAuthorizedEvent.kt @@ -9,7 +9,7 @@ internal fun makeCustomerIdentity( firstName: String? = nextString(), lastName: String? = nextString(), ) = CustomerIdentityModel( - idOnExternalPlatform = idOnExternalPlatform, + idOnExternalPlatform = idOnExternalPlatform.toString(), firstName = firstName, lastName = lastName ) diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/MessageModel.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/MessageModel.kt index 5ad4ba58..205574ac 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/model/MessageModel.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/model/MessageModel.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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. + */ + @file:Suppress("LongParameterList") package com.nice.cxonechat.model @@ -13,11 +28,13 @@ import com.nice.cxonechat.internal.model.network.UserStatistics import java.util.Date import java.util.UUID +private val linearTime = generateSequence(Date()) { Date(it.time + 1) }.iterator() + internal fun makeMessageModel( idOnExternalPlatform: UUID = UUID.randomUUID(), threadIdOnExternalPlatform: UUID = UUID.randomUUID(), messageContent: MessagePolyContent = makeMessageContent(), - createdAt: Date = Date(), + createdAt: Date = linearTime.next(), attachments: List<AttachmentModel> = emptyList(), direction: MessageDirectionModel = ToAgent, userStatistics: UserStatistics = makeUserStatistics(), diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequest.kt index 40472fc6..31f2098c 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequest.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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. - */ - @file:Suppress("MaxLineLength", "TestFunctionName") package com.nice.cxonechat.server diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequestAssertions.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequestAssertions.kt index 6df26b3f..110bfc4c 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequestAssertions.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerRequestAssertions.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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.server import com.nice.cxonechat.enums.EventType diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerResponse.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerResponse.kt index 53743872..eab11f6f 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerResponse.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/server/ServerResponse.kt @@ -21,9 +21,11 @@ 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.EventType 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.model.network.EventCaseStatusChanged.CaseStatus import com.nice.cxonechat.model.makeAgent import com.nice.cxonechat.model.makeChatThread import com.nice.cxonechat.model.makeMessageModel @@ -32,6 +34,8 @@ import com.nice.cxonechat.thread.ChatThread 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 @@ -41,7 +45,7 @@ import java.util.UUID internal object ServerResponse { fun ConsumerAuthorized( - identity: UUID = TestUUIDValue, + identity: String = TestUUIDValue.toString(), firstName: String = "firstName", lastName: String = "lastName", accessToken: String = "access-token", @@ -217,7 +221,7 @@ internal object ServerResponse { val postback = object { val eventType = "ThreadListFetched" val data = object { - val threads = threads.map { it.toReceived() } + val threads = threads.map(ChatThread::toReceived) } } }.serialize() @@ -263,6 +267,13 @@ internal object ServerResponse { } }.serialize() + fun ErrorResponse(errorCode: String) = object { + val error = object { + val errorCode = errorCode + val transactionId = UUID.randomUUID() + } + }.serialize() + fun MessageCreated( thread: ChatThread, message: MessageModel, @@ -284,6 +295,35 @@ internal object ServerResponse { } }.serialize() + fun MessageReadChanged( + message: MessageModel, + temporaryTypeAdapters: Map<Type, Any> = emptyMap(), + ) = object { + val eventId = TestUUID + val eventType = "MessageReadChanged".also { assert(it == EventType.MessageReadChanged.value) } + val data = object { + val message = message.copy( + userStatistics = message.userStatistics.copy(readAt = Date(0)) + ) + } + }.serialize(temporaryTypeAdapters) + + fun CaseStatusChanged( + thread: ChatThread, + status: CaseStatus, + ) = object { + val eventId = TestUUID + val eventType = "CaseStatusChanged".also { assert(it == EventType.CaseStatusChanged.value) } + val createdAt = DateTime(Date(0)) + val data = object { + val case = object { + val threadIdOnExternalPlatform = thread.id + val status = status + val statusUpdatedAt = DateTime(Date(0)) + } + } + }.serialize() + object Message { private operator fun invoke(threadId: UUID, content: Any) = object { diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/state/FieldDefinitionListTests.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/state/FieldDefinitionListTests.kt index 7f476d9c..20d58450 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/state/FieldDefinitionListTests.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/state/FieldDefinitionListTests.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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.state import com.nice.cxonechat.exceptions.InvalidCustomFieldValue diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/storage/PreferencesValueStorageTest.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/storage/PreferencesValueStorageTest.kt index 82ddb958..8b200bfa 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/storage/PreferencesValueStorageTest.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/storage/PreferencesValueStorageTest.kt @@ -4,21 +4,15 @@ import android.content.SharedPreferences import android.content.SharedPreferences.Editor import com.nice.cxonechat.tool.getPublicProperties import com.nice.cxonechat.tool.nextString -import org.junit.Before +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify import org.junit.Test -import org.mockito.ArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoMoreInteractions -import org.mockito.kotlin.verifyZeroInteractions -import org.mockito.kotlin.whenever import kotlin.reflect.KMutableProperty +import kotlin.reflect.KProperty import kotlin.test.assertEquals -import kotlin.test.assertTrue /** * This test objective is to ensure that the [SharedPreferences] are used correctly. @@ -29,17 +23,17 @@ import kotlin.test.assertTrue */ internal class PreferencesValueStorageTest { - private lateinit var storage: ValueStorage - private lateinit var sharedPreferences: SharedPreferences - private lateinit var editor: Editor - - @Before - fun setup() { - sharedPreferences = mock() - editor = mock() - whenever(sharedPreferences.edit()).thenReturn(editor) - storage = PreferencesValueStorage(sharedPreferences) + private val editor = mockk<Editor>() + private val sharedPreferences = mockk<SharedPreferences> { + every { edit() } returns editor } + private val storage: ValueStorage = PreferencesValueStorage(sharedPreferences) + private val stringProperties + get() = storage::class + .members + .getPublicProperties() + .filter { it.returnType.classifier == String::class } + .map { it as KMutableProperty<String> } /** * Test that all supported property types from [ValueStorage] interface a properly stored to [SharedPreferences] using unique key for @@ -47,21 +41,26 @@ internal class PreferencesValueStorageTest { */ @Test fun storageIsStoringValues() { - val properties = storage::class.members.getPublicProperties() - val usedKeys = ArgumentCaptor.forClass(String::class.java) - val uniqueUsedKeys = mutableSetOf<String>() - val filledProperties = properties.fillStorageWithRandomValues() - filledProperties.forEach { value -> - when (value) { - is String -> verify(editor).putString(usedKeys.capture(), eq(value)) - } - assertTrue(uniqueUsedKeys.add(usedKeys.value), "Key ${usedKeys.value} was already used to store different property") + val keys = mutableListOf<String>() + + every { editor.putString(capture(keys), any()) } returns editor + justRun { editor.apply() } + + for (property in stringProperties) { + val value = nextString() + + property.set(value) + + verify { + editor.putString(any(), eq(value)) + editor.apply() } - val testedProperties = filledProperties.size - verify(sharedPreferences, times(testedProperties)).edit() - verify(editor, times(testedProperties)).apply() - verifyNoMoreInteractions(editor) - verifyNoMoreInteractions(sharedPreferences) + } + + confirmVerified(editor) + + // Insure that there are no duplicated keys + assertEquals(keys, keys.toSet().toList(), "Duplicated key used in ValueStorage: $keys") } /** @@ -70,28 +69,21 @@ internal class PreferencesValueStorageTest { @Test fun storageIsRetrievingValues() { val testStringValue = nextString() - whenever(sharedPreferences.getString(any(), anyOrNull())).thenReturn(testStringValue) - val properties = storage::class.members.getPublicProperties() - var stringCount = 0 - properties.forEach { - when (it.returnType.classifier) { - String::class -> { - assertEquals(testStringValue, getStoredProperty(it)) - stringCount++ - } - } + + every { sharedPreferences.getString(any(), any()) } returns testStringValue + + for (property in stringProperties) { + assertEquals(testStringValue, property.get()) } - verify(sharedPreferences, times(stringCount)).getString(any(), anyOrNull()) - verifyNoMoreInteractions(sharedPreferences) - verifyZeroInteractions(editor) + + verify(exactly = stringProperties.size) { + sharedPreferences.getString(any(), any()) + } + confirmVerified(sharedPreferences) + confirmVerified(editor) } - private fun getStoredProperty(property: KMutableProperty<*>) = property.getter.call(storage) + private fun KProperty<*>.get() = getter.call(storage) - private fun List<KMutableProperty<*>>.fillStorageWithRandomValues(): List<Any> = mapNotNull { property -> - when (property.returnType.classifier) { - String::class -> nextString() - else -> null // not implemented - }?.also { property.setter.call(storage, it) } - } + private fun <T> KMutableProperty<T>.set(value: T) = setter.call(storage, value) } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Concurrency.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Concurrency.kt index 79905864..f85d52c3 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Concurrency.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Concurrency.kt @@ -23,5 +23,6 @@ internal inline fun <T> awaitResult( } finally { if (bodyResult is Cancellable) bodyResult.cancel() } + @Suppress("UNCHECKED_CAST") return result as T } diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockInterceptor.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockInterceptor.kt index b6336fab..d1a5b969 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockInterceptor.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockInterceptor.kt @@ -1,18 +1,3 @@ -/* - * Copyright (c) 2021-2023. 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.tool import okhttp3.Interceptor diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockServer.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockServer.kt index 925c517e..25d9df15 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockServer.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/MockServer.kt @@ -1,12 +1,15 @@ package com.nice.cxonechat.tool import com.nice.cxonechat.internal.socket.ProxyWebSocketListener +import io.mockk.every +import io.mockk.mockk import okhttp3.WebSocket -import org.mockito.kotlin.mock internal class MockServer { - val socket: WebSocket = mock() + val socket: WebSocket = mockk { + every { send(text = any()) } returns true + } val proxyListener: ProxyWebSocketListener = ProxyWebSocketListener() fun sendServerMessage(text: String) { diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/RepeatRule.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/RepeatRule.kt new file mode 100644 index 00000000..eefc316e --- /dev/null +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/RepeatRule.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2021-2023. 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.tool + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * To perform a single tested repeatedly: + * + * 1. add a `RepeatRule` to the test class: + * @get:Rule + * val repeatRule = RepeatRule() + * 2. add a `RepeatTest` annotation to the specific test class. + * @Test + * @RepeatTest(20) + * fun testSomething() + */ +internal class RepeatRule : TestRule { + private class RepeatStatement(private val statement: Statement, private val repeat: Int) : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + @Suppress("UnusedPrivateProperty") + for (i in 0 until repeat) { + statement.evaluate() + } + } + } + + override fun apply(statement: Statement, description: Description): Statement { + var result = statement + val repeat = description.getAnnotation(RepeatTest::class.java) + if (repeat != null) { + val times = repeat.value + result = RepeatStatement(statement, times) + } + return result + } +} + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS) +internal annotation class RepeatTest(val value: Int = 1) diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Serializer.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Serializer.kt index 97c4b25e..6e03cca0 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Serializer.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Serializer.kt @@ -1,5 +1,35 @@ +/* + * Copyright (c) 2021-2023. 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.tool import com.nice.cxonechat.internal.serializer.Default +import java.lang.reflect.Type + +internal fun Any.serialize(temporarySubtypes: Map<Type, Any> = emptyMap()): String = Default.serializer + .let { gson -> + when { + temporarySubtypes.isNotEmpty() -> { + val builder = gson.newBuilder() + for ((type, adapter) in temporarySubtypes) { + builder.registerTypeAdapter(type, adapter) + } + builder.create() + } -internal fun Any.serialize(): String = Default.serializer.toJson(this) + else -> gson + } + } + .toJson(this) diff --git a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Threading.kt b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Threading.kt index 9b023b3f..8d7fbc61 100644 --- a/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Threading.kt +++ b/chat-sdk-core/src/test/java/com/nice/cxonechat/tool/Threading.kt @@ -2,19 +2,18 @@ package com.nice.cxonechat.tool import com.nice.cxonechat.internal.Threading import com.nice.cxonechat.internal.ThreadingExecutor -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever +import io.mockk.every +import io.mockk.mockk import java.util.concurrent.ExecutorService import java.util.concurrent.FutureTask internal val Threading.Companion.Identity: ThreadingExecutor get() { - val executor: ExecutorService = mock() - whenever(executor.submit(any())).then { - val runnable = it.getArgument<Runnable>(0) - runnable.run() - FutureTask({}, Unit) + val executor: ExecutorService = mockk { + every { submit(any()) } answers { + arg<Runnable>(0).run() + FutureTask({}, Unit) + } } return ThreadingExecutor(executor, executor) } diff --git a/chat-sdk-ui/README.md b/chat-sdk-ui/README.md new file mode 100644 index 00000000..0ac8a0b4 --- /dev/null +++ b/chat-sdk-ui/README.md @@ -0,0 +1,9 @@ +# About + +This is a sample implementation of the UI for CXOne Chat SDK, which allows easier integration of SDK into +the intended target application. + +## Requirements: + +This module requires that integrating uses Koin during it's build process and it also has to provide instance of `Logger` +with `UiQualifier`. diff --git a/chat-sdk-ui/build.gradle b/chat-sdk-ui/build.gradle index 0ad64f55..c8da872c 100644 --- a/chat-sdk-ui/build.gradle +++ b/chat-sdk-ui/build.gradle @@ -1,12 +1,12 @@ plugins { id "android-library-conventions" id "android-ui-conventions" - id "kotlin-conventions" - id "kapt-conventions" - id "test-conventions" - id "library-style-conventions" + id "android-kotlin-conventions" + id "ksp-conventions" + id "koin-conventions" + id "android-test-conventions" + id "android-library-style-conventions" id 'androidx.navigation.safeargs.kotlin' - id "com.google.dagger.hilt.android" } android { @@ -26,39 +26,44 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + + sourceSets { + main { + java.srcDirs += new File(buildDir, "generated/ksp/main/kotlin") + } + } } dependencies { - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.3.1' - implementation 'com.google.android.material:material:1.7.0' + // GSON is used for parsing of payload in Plugin Custom messages implementation 'com.google.code.gson:gson:2.10.1' // Handling of push notification sent via FCM - implementation platform('com.google.firebase:firebase-bom:32.2.2') + implementation platform('com.google.firebase:firebase-bom:32.7.1') implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging-ktx' // Lifecycle-process is used to suppress push notifications when app is in foreground - implementation "androidx.lifecycle:lifecycle-process:2.6.1" + implementation "androidx.lifecycle:lifecycle-process:2.7.0" - implementation("com.google.dagger:hilt-android:$daggerHiltVersion") - kapt("com.google.dagger:hilt-android-compiler:$daggerHiltVersion") - implementation("androidx.hilt:hilt-navigation-compose:1.0.0") - - def markdownVersion = "0.3.4" + def markdownVersion = "0.4.1" implementation "com.github.jeziellago:compose-markdown:$markdownVersion" // Async Image - implementation("io.coil-kt:coil-compose:2.4.0") + implementation("io.coil-kt:coil-compose:2.5.0") // Zoomable composable elements like Image & AsyncImage - implementation "net.engawapg.lib:zoomable:1.4.3" + implementation "net.engawapg.lib:zoomable:1.5.3" // Multimedia message playback - def media3Version = "1.1.0" + def media3Version = "1.2.1" implementation "androidx.media3:media3-exoplayer:$media3Version" + implementation "androidx.media3:media3-datasource-okhttp:$media3Version" implementation "androidx.media3:media3-ui:$media3Version" // CXOne Chat SDK implementation project(":chat-sdk-core") + implementation project(":logger-android") + implementation project(':utilities') + + // Immutable annotations + implementation "com.google.code.findbugs:jsr305:3.0.2" } diff --git a/chat-sdk-ui/config/detekt/detekt-baseline.xml b/chat-sdk-ui/config/detekt/detekt-baseline.xml index 7cd82039..8c7b7c9a 100644 --- a/chat-sdk-ui/config/detekt/detekt-baseline.xml +++ b/chat-sdk-ui/config/detekt/detekt-baseline.xml @@ -1,8 +1,5 @@ <?xml version='1.0' encoding='UTF-8'?> <SmellBaseline> <ManuallySuppressedIssues/> - <CurrentIssues> - <ID>ForbiddenImport:AudioRecordingViewModel.kt$import android.util.Log</ID> - <ID>ForbiddenImport:PushListenerService.kt$import android.util.Log</ID> - </CurrentIssues> + <CurrentIssues /> </SmellBaseline> diff --git a/chat-sdk-ui/lint-baseline.xml b/chat-sdk-ui/lint-baseline.xml index 8fe5f265..4157253c 100644 --- a/chat-sdk-ui/lint-baseline.xml +++ b/chat-sdk-ui/lint-baseline.xml @@ -1,1046 +1,33 @@ <?xml version="1.0" encoding="UTF-8"?> -<issues name="AGP (7.3.1)" by="lint 7.3.1" client="gradle" dependencies="false" format="6" type="baseline" variant="all" version="7.3.1"> +<issues format="6" by="lint 8.1.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.2)" variant="all" version="8.1.2"> <issue - id="InvalidPackage" - message="Invalid package reference in org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm; not included in Android: `java.lang.instrument`. Referenced from `kotlinx.coroutines.debug.AgentPremain`."> + id="PluralsCandidate" + message="Formatting %d followed by words ("others"): This should probably be a plural rather than a string" + errorLine1=" <string name="share_attachment_others">Share attachment %1$s +%2$d others</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> <location - file="$GRADLE_USER_HOME/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm/1.6.4/2c997cd1c0ef33f3e751d3831929aeff1390cb30/kotlinx-coroutines-core-jvm-1.6.4.jar"/> - </issue> - - <issue - errorLine1="open class SampleAppBaseActivity: AppCompatActivity() {" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~" - id="Registered" - message="The `<activity> com.nice.cxonechat.sample.common.SampleAppBaseActivity` is not registered in the manifest"> - <location - column="12" - file="src/main/java/com/nice/cxonechat/sample/common/SampleAppBaseActivity.kt" - line="6"/> - </issue> - - <issue - errorLine1=" implementation platform('com.google.firebase:firebase-bom:28.2.1')" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="GradleDependency" - message="A newer version of com.google.firebase:firebase-bom than 28.2.1 is available: 31.1.1"> - <location - column="29" - file="build.gradle" - line="62"/> - </issue> - - <issue - errorLine1=" <string name="ok">Ok</string>" - errorLine2=" ^" - id="Typos" - message=""Ok" is usually capitalized as "OK""> - <location - column="23" file="src/main/res/values/strings.xml" - line="10"/> + line="122" + column="5"/> </issue> <issue - id="ObsoleteSdkInt" - message="This folder configuration (`v26`) is unnecessary; `minSdkVersion` is 26. Merge all the resources in this folder into `mipmap-anydpi`."> - <location - file="src/main/res/mipmap-anydpi-v26"/> - </issue> - - <issue - errorLine1=" Log.i("amazon", ae.message.toString())" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="LogConditional" - message="The log call Log.i(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`"> - <location - column="17" - file="src/main/java/com/nice/cxonechat/sample/LoginActivity.kt" - line="81"/> - </issue> - - <issue - errorLine1=" Log.i("amazon", cancellation.description)" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="LogConditional" - message="The log call Log.i(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`"> - <location - column="17" - file="src/main/java/com/nice/cxonechat/sample/LoginActivity.kt" - line="84"/> - </issue> - - <issue - errorLine1=" android:pathData="M24,34Q24.7,34 25.175,33.525Q25.65,33.05 25.65,32.35Q25.65,31.65 25.175,31.175Q24.7,30.7 24,30.7Q23.3,30.7 22.825,31.175Q22.35,31.65 22.35,32.35Q22.35,33.05 22.825,33.525Q23.3,34 24,34ZM22.65,26.35H25.65V13.7H22.65ZM24,44Q19.9,44 16.25,42.425Q12.6,40.85 9.875,38.125Q7.15,35.4 5.575,31.75Q4,28.1 4,23.95Q4,19.85 5.575,16.2Q7.15,12.55 9.875,9.85Q12.6,7.15 16.25,5.575Q19.9,4 24.05,4Q28.15,4 31.8,5.575Q35.45,7.15 38.15,9.85Q40.85,12.55 42.425,16.2Q44,19.85 44,24Q44,28.1 42.425,31.75Q40.85,35.4 38.15,38.125Q35.45,40.85 31.8,42.425Q28.15,44 24,44ZM24.05,41Q31.1,41 36.05,36.025Q41,31.05 41,23.95Q41,16.9 36.05,11.95Q31.1,7 24,7Q16.95,7 11.975,11.95Q7,16.9 7,24Q7,31.05 11.975,36.025Q16.95,41 24.05,41ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Z"/>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="VectorPath" - message="Very long vector path (803 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."> - <location - column="25" - file="src/main/res/drawable/error_48px.xml" - line="9"/> - </issue> - - <issue - errorLine1=" <color name="purple_200">#FFBB86FC</color>" - errorLine2=" ~~~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.color.purple_200` appears to be unused"> - <location - column="12" - file="src/main/res/values/colors.xml" - line="3"/> - </issue> - - <issue - errorLine1=" <color name="teal_200">#FF03DAC5</color>" - errorLine2=" ~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.color.teal_200` appears to be unused"> - <location - column="12" - file="src/main/res/values/colors.xml" - line="6"/> - </issue> - - <issue - errorLine1=" <color name="teal_700">#FF018786</color>" - errorLine2=" ~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.color.teal_700` appears to be unused"> - <location - column="12" - file="src/main/res/values/colors.xml" - line="7"/> - </issue> - - <issue - errorLine1="<set xmlns:android="http://schemas.android.com/apk/res/android"" - errorLine2="^" - id="UnusedResources" - message="The resource `R.anim.slide_enter_to_left` appears to be unused"> - <location - column="1" - file="src/main/res/anim/slide_enter_to_left.xml" - line="2"/> - </issue> - - <issue - errorLine1="<set xmlns:android="http://schemas.android.com/apk/res/android"" - errorLine2="^" - id="UnusedResources" - message="The resource `R.anim.slide_leave_to_left` appears to be unused"> - <location - column="1" - file="src/main/res/anim/slide_leave_to_left.xml" - line="2"/> - </issue> - - <issue - errorLine1=" <string name="new_message_received">New message received</string>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.string.new_message_received` appears to be unused"> - <location - column="13" - file="src/main/res/values/strings.xml" - line="17"/> - </issue> - - <issue - errorLine1=" <string name="socket_disconnected">Socket disconnected</string>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.string.socket_disconnected` appears to be unused"> - <location - column="13" - file="src/main/res/values/strings.xml" - line="18"/> - </issue> - - <issue - errorLine1=" <string name="configuration_error">Something went wrong and we couldn\'t get the configuration for that channel. Please check your selection and try again.</string>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.string.configuration_error` appears to be unused"> - <location - column="13" - file="src/main/res/values/strings.xml" - line="27"/> - </issue> - - <issue - errorLine1=" <style name="AppTheme.NoActionBar">" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.style.AppTheme_NoActionBar` appears to be unused"> - <location - column="12" - file="src/main/res/values/styles.xml" - line="10"/> - </issue> - - <issue - errorLine1=" <style name="Theme.AndroidSDK" parent="Theme.AppCompat.Light.DarkActionBar">" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedResources" - message="The resource `R.style.Theme_AndroidSDK` appears to be unused"> - <location - column="12" - file="src/main/res/values/themes.xml" - line="3"/> - </issue> - - <issue - errorLine1=" viewModel.sendAttachment(null, safeUri)" - errorLine2=" ~~~~~~~~~" - id="SyntheticAccessor" - message="Access to `private` method `getViewModel` of class `ChatThreadFragment` requires synthetic accessor"> - <location - column="17" - file="src/main/java/com/nice/cxonechat/sample/ui/main/ChatThreadFragment.kt" - line="428"/> - </issue> - - <issue - errorLine1=" viewModel.archiveThread(thread.chatThread)" - errorLine2=" ~~~~~~~~~" - id="SyntheticAccessor" - message="Access to `private` method `getViewModel` of class `ChatThreadsFragment` requires synthetic accessor"> - <location - column="17" - file="src/main/java/com/nice/cxonechat/sample/ui/main/ChatThreadsFragment.kt" - line="67"/> - </issue> - - <issue - errorLine1=" gson.fromJson(postback, PostBackPolyContent::class.java)" - errorLine2=" ~~~~" - id="SyntheticAccessor" - message="Access to `private` method `getGson` of class `Companion` requires synthetic accessor"> - <location - column="17" - file="src/main/java/com/nice/cxonechat/sample/custom/holders/IncomingTextAndButtonsMessageViewHolder.kt" - line="181"/> - </issue> - - <issue - errorLine1=" .registerTypeAdapterFactory(postbackContentAdapter)" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~" - id="SyntheticAccessor" - message="Access to `private` method `getPostbackContentAdapter` of class `Companion` requires synthetic accessor"> - <location - column="45" - file="src/main/java/com/nice/cxonechat/sample/custom/holders/IncomingTextAndButtonsMessageViewHolder.kt" - line="213"/> - </issue> - - <issue - errorLine1=" <LinearLayout" - errorLine2=" ~~~~~~~~~~~~" - id="UselessParent" - message="This `LinearLayout` layout or its `LinearLayout` parent is unnecessary"> - <location - column="6" - file="src/main/res/layout/activity_login.xml" - line="10"/> - </issue> - - <issue - errorLine1=" <LinearLayout" - errorLine2=" ~~~~~~~~~~~~" - id="UselessParent" - message="This `LinearLayout` layout or its `LinearLayout` parent is unnecessary"> - <location - column="10" - file="src/main/res/layout/thread_item.xml" - line="19"/> - </issue> - - <issue - errorLine1=" android:id="@+id/photo_view"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.photo_view` appears to be unused"> - <location - column="9" - file="src/main/res/layout/activity_image_preview.xml" - line="28"/> - </issue> - - <issue - errorLine1=" android:id="@+id/guest_button"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.guest_button` appears to be unused"> - <location - column="13" - file="src/main/res/layout/activity_login.xml" - line="16"/> - </issue> - - <issue - errorLine1=" android:id="@+id/login_with_amazon"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.login_with_amazon` appears to be unused"> - <location - column="13" - file="src/main/res/layout/activity_login.xml" - line="25"/> - </issue> - - <issue - errorLine1=" android:id="@+id/videoView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.videoView` appears to be unused"> - <location - column="9" - file="src/main/res/layout/activity_video_preview.xml" - line="10"/> - </issue> - - <issue - errorLine1=" android:id="@+id/homeConfigurationFragmentId"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.homeConfigurationFragmentId` appears to be unused"> - <location - column="5" - file="src/main/res/navigation/configuration.xml" - line="5"/> - </issue> - - <issue - errorLine1=" android:id="@+id/first_name_text_input_layout"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.first_name_text_input_layout` appears to be unused"> - <location - column="9" - file="src/main/res/layout/create_customer_dialog.xml" - line="11"/> - </issue> - - <issue - errorLine1=" android:id="@+id/last_name_text_input_layout"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.last_name_text_input_layout` appears to be unused"> - <location - column="9" - file="src/main/res/layout/create_customer_dialog.xml" - line="25"/> - </issue> - - <issue - errorLine1=" android:id="@+id/accept_button"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.accept_button` appears to be unused"> - <location - column="9" - file="src/main/res/layout/create_customer_dialog.xml" - line="39"/> - </issue> - - <issue - errorLine1=" android:id="@+id/location_spinner"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.location_spinner` appears to be unused"> - <location - column="17" - file="src/main/res/layout/create_thread_dialog.xml" - line="36"/> - </issue> - - <issue - errorLine1=" android:id="@+id/department_spinner"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.department_spinner` appears to be unused"> - <location - column="17" - file="src/main/res/layout/create_thread_dialog.xml" - line="76"/> - </issue> - - <issue - errorLine1=" android:id="@+id/cancel_button"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.cancel_button` appears to be unused"> - <location - column="13" - file="src/main/res/layout/create_thread_dialog.xml" - line="95"/> - </issue> - - <issue - errorLine1=" android:id="@+id/create_thread"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.create_thread` appears to be unused"> - <location - column="13" - file="src/main/res/layout/create_thread_dialog.xml" - line="103"/> - </issue> - - <issue - errorLine1=" android:id="@+id/headingTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.headingTextView` appears to be unused"> - <location - column="21" - file="src/main/res/layout/custom_snack_bar.xml" - line="31"/> - </issue> - - <issue - errorLine1=" android:id="@+id/bodyTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.bodyTextView` appears to be unused"> - <location - column="21" - file="src/main/res/layout/custom_snack_bar.xml" - line="42"/> - </issue> - - <issue - errorLine1=" android:id="@+id/actionTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.actionTextView` appears to be unused"> - <location - column="21" - file="src/main/res/layout/custom_snack_bar.xml" - line="52"/> - </issue> - - <issue - errorLine1=" android:id="@+id/closeButton"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.closeButton` appears to be unused"> - <location - column="13" - file="src/main/res/layout/custom_snack_bar.xml" - line="75"/> - </issue> - - <issue - errorLine1=" android:id="@+id/action_thread_name"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.action_thread_name` appears to be unused"> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="5"/> - </issue> - - <issue - errorLine1=" android:id="@+id/action_favorite"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.action_favorite` appears to be unused"> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="11"/> - </issue> - - <issue - errorLine1=" android:id="@+id/action_logout"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.action_logout` appears to be unused"> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="17"/> - </issue> - - <issue - errorLine1=" android:id="@+id/messagesList"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.messagesList` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_chat_thread.xml" - line="9"/> - </issue> - - <issue - errorLine1=" android:id="@+id/agentTypingTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.agentTypingTextView` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_chat_thread.xml" - line="16"/> - </issue> - - <issue - errorLine1=" android:id="@+id/separator"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.separator` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_chat_thread.xml" - line="24"/> - </issue> - - <issue - errorLine1=" android:id="@+id/input"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.input` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_chat_thread.xml" - line="32"/> - </issue> - - <issue - errorLine1=" android:id="@+id/threads_recycler_view"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.threads_recycler_view` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_chat_threads.xml" - line="41"/> - </issue> - - <issue - errorLine1=" android:id="@+id/floating_action_button"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.floating_action_button` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_chat_threads.xml" - line="51"/> - </issue> - - <issue - errorLine1=" android:id="@+id/environmentTextInputLayout"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.environmentTextInputLayout` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_custom_configuration.xml" - line="17"/> - </issue> - - <issue - errorLine1=" android:id="@+id/environmentAutoCompleteTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.environmentAutoCompleteTextView` appears to be unused"> - <location - column="13" - file="src/main/res/layout/fragment_custom_configuration.xml" - line="27"/> - </issue> - - <issue - errorLine1=" android:id="@+id/brandIdTextInputLayout"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.brandIdTextInputLayout` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_custom_configuration.xml" - line="33"/> - </issue> - - <issue - errorLine1=" android:id="@+id/channelIdTextInputLayout"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.channelIdTextInputLayout` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_custom_configuration.xml" - line="52"/> - </issue> - - <issue - errorLine1=" android:id="@+id/useDefaultConfigurationTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.useDefaultConfigurationTextView` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_custom_configuration.xml" - line="69"/> - </issue> - - <issue - errorLine1=" android:id="@+id/continueButton"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.continueButton` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_custom_configuration.xml" - line="79"/> - </issue> - - <issue - errorLine1=" android:id="@+id/configurationTextInputLayout"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.configurationTextInputLayout` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_home_configuration.xml" - line="17"/> - </issue> - - <issue - errorLine1=" android:id="@+id/configurationAutoCompleteTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.configurationAutoCompleteTextView` appears to be unused"> - <location - column="13" - file="src/main/res/layout/fragment_home_configuration.xml" - line="27"/> - </issue> - - <issue - errorLine1=" android:id="@+id/useCustomConfigurationTextView"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.useCustomConfigurationTextView` appears to be unused"> - <location - column="9" - file="src/main/res/layout/fragment_home_configuration.xml" - line="33"/> - </issue> - - <issue - errorLine1=" android:id="@+id/thread_item_card_view"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.thread_item_card_view` appears to be unused"> - <location - column="5" - file="src/main/res/layout/thread_item.xml" - line="5"/> - </issue> - - <issue - errorLine1=" android:id="@+id/agent_image_view"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.agent_image_view` appears to be unused"> - <location - column="17" - file="src/main/res/layout/thread_item.xml" - line="26"/> - </issue> - - <issue - errorLine1=" android:id="@+id/thread_number_text_view"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.thread_number_text_view` appears to be unused"> - <location - column="21" - file="src/main/res/layout/thread_item.xml" - line="40"/> - </issue> - - <issue - errorLine1=" android:id="@+id/thread_last_message_text_view"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="UnusedIds" - message="The resource `R.id.thread_last_message_text_view` appears to be unused"> - <location - column="21" - file="src/main/res/layout/thread_item.xml" - line="53"/> - </issue> - - <issue - errorLine1=" <string name="create_thread">Create thread</string>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="DuplicateStrings" - message="Duplicate string value `Create thread`, used in `create_thread` and `dsc_create_thread`"> - <location - column="5" - file="src/main/res/values/strings.xml" - line="14"/> - <location - column="5" - file="src/main/res/values/strings.xml" - line="34" - message="Duplicates value in `create_thread`"/> - </issue> - - <issue - id="ConvertToWebp" - message="One or more images in this project can be converted to the WebP format which typically results in smaller file sizes, even for lossless conversion"> - <location - file="src/main/res/mipmap-xxxhdpi/ic_launcher_round.png"/> - </issue> - - <issue - id="IconLocation" - message="Found bitmap drawable `res/drawable/btnlwa_gold_loginwithamazon.png` in densityless folder"> - <location - file="src/main/res/drawable/btnlwa_gold_loginwithamazon.png"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="6" - file="src/main/res/layout/fragment_chat_thread.xml" - line="15"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="6" - file="src/main/res/layout/item_custom_incoming_text_and_buttons_message.xml" - line="27"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="10" - file="src/main/res/layout/item_custom_incoming_text_message.xml" - line="16"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="6" - file="src/main/res/layout/item_custom_incoming_text_message.xml" - line="25"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="10" - file="src/main/res/layout/item_custom_outcoming_text_and_buttons_message.xml" - line="25"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="6" - file="src/main/res/layout/item_custom_outcoming_text_and_buttons_message.xml" - line="36"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="10" - file="src/main/res/layout/item_custom_outcoming_text_message.xml" - line="18"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="6" - file="src/main/res/layout/item_custom_outcoming_text_message.xml" - line="37"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="18" - file="src/main/res/layout/thread_item.xml" - line="39"/> - </issue> - - <issue - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~" - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`"> - <location - column="18" - file="src/main/res/layout/thread_item.xml" - line="52"/> - </issue> - - <issue - errorLine1=" <Button" - errorLine2=" ~~~~~~" - id="ButtonStyle" - message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"> - <location - column="10" - file="src/main/res/layout/create_thread_dialog.xml" - line="94"/> - </issue> - - <issue - errorLine1=" <Button" - errorLine2=" ~~~~~~" - id="ButtonStyle" - message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"> - <location - column="10" - file="src/main/res/layout/create_thread_dialog.xml" - line="102"/> - </issue> - - <issue - errorLine1=" app:showAsAction="always"/>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~" - id="AlwaysShowAction" - message="Prefer "`ifRoom`" instead of "`always`""> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="8"/> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="14"/> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="20"/> - </issue> - - <issue - errorLine1=" <string name="ok">Ok</string>" - errorLine2=" ~~" - id="ButtonCase" - message="The standard Android way to capitalize Ok is "OK" (tip: use `@android:string/ok` instead)"> - <location - column="23" - file="src/main/res/values/strings.xml" - line="10"/> - </issue> - - <issue - errorLine1=" <ImageButton" - errorLine2=" ~~~~~~~~~~~" id="ContentDescription" - message="Missing `contentDescription` attribute on image"> - <location - column="10" - file="src/main/res/layout/activity_login.xml" - line="24"/> - </issue> - - <issue + message="Missing `contentDescription` attribute on image" errorLine1=" <ImageButton" - errorLine2=" ~~~~~~~~~~~" - id="ContentDescription" - message="Missing `contentDescription` attribute on image"> + errorLine2=" ~~~~~~~~~~~"> <location - column="10" file="src/main/res/layout/custom_snack_bar.xml" - line="74"/> - </issue> - - <issue - errorLine1=" <ImageView" - errorLine2=" ~~~~~~~~~" - id="ContentDescription" - message="Missing `contentDescription` attribute on image"> - <location - column="14" - file="src/main/res/layout/thread_item.xml" - line="25"/> - </issue> - - <issue - errorLine1=" <ImageView" - errorLine2=" ~~~~~~~~~" - id="ContentDescription" - message="Missing `contentDescription` attribute on image"> - <location - column="14" - file="src/main/res/layout/thread_item.xml" - line="66"/> - </issue> - - <issue - errorLine1=" <AutoCompleteTextView" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~" - id="LabelFor" - message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"> - <location - column="10" - file="src/main/res/layout/fragment_custom_configuration.xml" - line="26"/> - </issue> - - <issue - errorLine1=" <AutoCompleteTextView" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~" - id="LabelFor" - message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"> - <location - column="10" - file="src/main/res/layout/fragment_home_configuration.xml" - line="26"/> - </issue> - - <issue - errorLine1=" configurationAutoCompleteTextView.setText("CD", false)" - errorLine2=" ~~" - id="SetTextI18n" - message="String literal in `setText` can not be translated. Use Android resources instead."> - <location - column="56" - file="src/main/java/com/nice/cxonechat/sample/ui/config/HomeConfigurationFragment.kt" - line="61"/> - </issue> - - <issue - errorLine1=" tvDuration.text = "12min."" - errorLine2=" ~~~~~~" - id="SetTextI18n" - message="String literal in `setText` can not be translated. Use Android resources instead."> - <location - column="28" - file="src/main/java/com/nice/cxonechat/sample/custom/holders/OutcomingTextAndButtonsMessageViewHolder.kt" - line="19"/> - </issue> - - <issue - errorLine1=" tvTime.text = "custom plugin"" - errorLine2=" ~~~~~~~~~~~~~" - id="SetTextI18n" - message="String literal in `setText` can not be translated. Use Android resources instead."> - <location - column="24" - file="src/main/java/com/nice/cxonechat/sample/custom/holders/OutcomingTextAndButtonsMessageViewHolder.kt" - line="20"/> - </issue> - - <issue - errorLine1=" android:title="Set Name"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~" - id="HardcodedText" - message="Hardcoded string "Set Name", should use `@string` resource"> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="7"/> - </issue> - - <issue - errorLine1=" android:title="Edit"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~" - id="HardcodedText" - message="Hardcoded string "Edit", should use `@string` resource"> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="13"/> - </issue> - - <issue - errorLine1=" android:title="SignOut"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~" - id="HardcodedText" - message="Hardcoded string "SignOut", should use `@string` resource"> - <location - column="9" - file="src/main/res/menu/default_menu.xml" - line="19"/> - </issue> - - <issue - errorLine1=" android:text="CUSTOM PLUGIN" />" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~" - id="HardcodedText" - message="Hardcoded string "CUSTOM PLUGIN", should use `@string` resource"> - <location - column="13" - file="src/main/res/layout/item_custom_outcoming_text_and_buttons_message.xml" - line="23"/> - </issue> - - <issue - errorLine1=" public CustomOutcomingTextMessageViewHolder(View itemView, Object payload) {" - errorLine2=" ~~~~" - id="UnknownNullness" - message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"> - <location - column="49" - file="src/main/java/com/nice/cxonechat/sample/custom/holders/CustomOutcomingTextMessageViewHolder.java" - line="15"/> - </issue> - - <issue - errorLine1=" public CustomOutcomingTextMessageViewHolder(View itemView, Object payload) {" - errorLine2=" ~~~~~~" - id="UnknownNullness" - message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"> - <location - column="64" - file="src/main/java/com/nice/cxonechat/sample/custom/holders/CustomOutcomingTextMessageViewHolder.java" - line="15"/> + line="89" + column="10"/> </issue> <issue - errorLine1=" public void onBind(Message message) {" - errorLine2=" ~~~~~~~" - id="UnknownNullness" - message="Unknown nullability; explicitly declare as `@Nullable` or `@NonNull` to improve Kotlin interoperability; see https://developer.android.com/kotlin/interop#nullability_annotations"> + id="RtlEnabled" + message="The project references RTL attributes, but does not explicitly enable or disable RTL support with `android:supportsRtl` in the manifest"> <location - column="24" - file="src/main/java/com/nice/cxonechat/sample/custom/holders/CustomOutcomingTextMessageViewHolder.java" - line="22"/> + file="src/main/AndroidManifest.xml"/> </issue> </issues> diff --git a/chat-sdk-ui/src/main/AndroidManifest.xml b/chat-sdk-ui/src/main/AndroidManifest.xml index 66dc2038..4814db22 100644 --- a/chat-sdk-ui/src/main/AndroidManifest.xml +++ b/chat-sdk-ui/src/main/AndroidManifest.xml @@ -46,15 +46,6 @@ </queries> <application tools:targetApi="q"> - <activity - android:name=".VideoPreviewActivity" - android:configChanges="orientation|keyboardHidden" - android:exported="false" - android:screenOrientation="landscape" /> - <activity - android:name=".ImagePreviewActivity" - android:exported="false" /> - <meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="@string/default_notification_channel_id" /> @@ -62,7 +53,7 @@ android:name=".ChatActivity" android:exported="false" android:theme="@style/ChatTheme" - /> + android:launchMode="singleTop" /> <service android:name=".PushListenerService" 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 b8e9d650..7e12de92 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,79 +15,81 @@ package com.nice.cxonechat.ui -import android.content.res.Resources -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable +import android.app.Activity +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.MenuItem import android.view.WindowManager.LayoutParams -import androidx.activity.viewModels +import androidx.annotation.AnimRes +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.Toolbar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.res.stringResource import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.State.DESTROYED import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment -import coil.ImageLoader -import coil.request.ImageRequest -import coil.target.Target import com.google.android.material.snackbar.Snackbar import com.nice.cxonechat.ChatState import com.nice.cxonechat.ChatState.CONNECTED import com.nice.cxonechat.ChatState.CONNECTING -import com.nice.cxonechat.ChatState.CONNECTION_CLOSED import com.nice.cxonechat.ChatState.CONNECTION_LOST import com.nice.cxonechat.ChatState.INITIAL +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.ui.R.anim import com.nice.cxonechat.ui.R.string import com.nice.cxonechat.ui.composable.theme.ChatTheme -import com.nice.cxonechat.ui.composable.theme.ChatTheme.images -import com.nice.cxonechat.ui.customvalues.mergeWithCustomField import com.nice.cxonechat.ui.databinding.ActivityMainBinding import com.nice.cxonechat.ui.main.ChatStateViewModel import com.nice.cxonechat.ui.main.ChatThreadsViewModel import com.nice.cxonechat.ui.main.ChatViewModel -import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.CustomValues -import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.EditThreadName 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.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.describe import com.nice.cxonechat.ui.util.Ignored import com.nice.cxonechat.ui.util.isEmpty import com.nice.cxonechat.ui.util.showAlert -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.util.UUID +import java.util.concurrent.CancellationException /** * Chat container activity. */ -@AndroidEntryPoint @Public @Suppress("TooManyFunctions") -class ChatActivity : - Toolbar.OnMenuItemClickListener, - AppCompatActivity() { - private var toolbar: Toolbar? = null - private val chatViewModel: ChatViewModel by viewModels() - private val chatThreadsViewModel: ChatThreadsViewModel by viewModels() - private val chatStateViewModel: ChatStateViewModel by viewModels() +class ChatActivity : AppCompatActivity() { + private val chatViewModel: ChatViewModel by viewModel() + private val chatThreadsViewModel: ChatThreadsViewModel by viewModel() + private val chatStateViewModel: ChatStateViewModel by viewModel() + private val closing + get() = lifecycle.currentState == DESTROYED @Suppress("LateinitUsage") private lateinit var binding: ActivityMainBinding @@ -97,51 +99,104 @@ class ChatActivity : field?.dismiss() field = value } - private val Int.toPx - get() = (this * Resources.getSystem().displayMetrics.density).toInt() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + applyFixesForKeyboardInput() binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - val toolbarById = findViewById<Toolbar>(R.id.my_toolbar) - toolbar = toolbarById - toolbarById.title = "" - setSupportActionBar(this.toolbar) setupComposableUi() - toolbarById.inflateMenu(R.menu.default_menu) - toolbarById.setOnMenuItemClickListener(this) + registerHandler(::handleChatState) + registerChatModelStateHandler() + registerHandler(::handleErrorStates) + } + private fun registerChatModelStateHandler() { lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - chatViewModel.state.collect { state -> - when (state) { - Initial -> Ignored - - is NavigationState -> { - if (state is MultiThreadEnabled) { - observeBackgroundThreadUpdates() - } - startFragmentNavigation(state) - } - - is State.SingleThreadPreChatSurveyRequired -> chatViewModel.showPreChatSurvey(state.survey) - State.SingleThreadCreationReady -> chatViewModel.createThread() - is State.SingleThreadCreationFailed -> showOnThreadCreationFailure(state) + repeatOnLifecycle(Lifecycle.State.RESUMED) { + var job: Job? = null + chatStateViewModel.state.collect { + job = if (it === READY) { + handleChatModelState() + } else { + job?.cancel(CancellationException("State: $it")) + null } } } } + } + + private fun CoroutineScope.handleChatModelState() = launch { + chatViewModel.state.collect { state -> + when (state) { + Initial -> Ignored + + is NavigationState -> { + if (state is MultiThreadEnabled || state is NavigationFinished) { + observeBackgroundThreadUpdates() + } + startFragmentNavigation(state) + if (state is MultiThreadEnabled) { + intent?.handleDeeplink() + } + } + + CreateSingleThread -> chatViewModel.createThread() + is SingleThreadPreChatSurveyRequired -> chatViewModel.showPreChatSurvey(state.survey) + is SingleThreadCreationFailed -> showOnThreadCreationFailure(state) + } + } + } + + private fun registerHandler( + handler: suspend () -> Unit, + repeatOnLifecycleState: Lifecycle.State = Lifecycle.State.RESUMED, + ) { lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - registerChatStateUiHandler() + repeatOnLifecycle(repeatOnLifecycleState) { + handler() + } + } + } + + 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) } } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + intent?.handleDeeplink() + } + } + } + + override fun onPause() { + super.onPause() + chatViewModel.close() + } + /** * This is workaround for issue when keyboard is shown window content pans under the toolbar and keyboard overlaps * window contents. @@ -150,7 +205,6 @@ class ChatActivity : @Suppress("DEPRECATION") private fun applyFixesForKeyboardInput() { if (VERSION.SDK_INT >= VERSION_CODES.R) window.setDecorFitsSystemWindows(true) - @Suppress("DEPRECATION") window.setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE) } @@ -164,37 +218,10 @@ class ChatActivity : } } - override fun setTitle(title: CharSequence?) { - super.setTitle(title) - - supportActionBar?.title = title - } - - private fun setLogo(model: Any?) { - val context = this - val imageLoader = ImageLoader(context) - val target = object : Target { - override fun onError(error: Drawable?) { - supportActionBar?.setLogo(null) - } - - override fun onSuccess(result: Drawable) { - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setDisplayUseLogoEnabled(true) - supportActionBar?.setLogo(result) - } - } - val imageRequest = ImageRequest.Builder(context) - .size(25.toPx) - .data(model) - .target(target) - .build() - imageLoader.enqueue(imageRequest) - } - override fun finish() { super.finish() - overridePendingTransition(R.anim.dismiss_host, R.anim.dismiss_chat) + + overrideCloseAnimation(anim.dismiss_host, anim.dismiss_chat) } private fun setupComposableUi() { @@ -202,39 +229,12 @@ class ChatActivity : ChatTheme { when (val dialog = chatViewModel.dialogShown.collectAsState().value) { None -> Ignored - CustomValues -> BuildEditCustomValues() - EditThreadName -> EditThreadName() is Survey -> BuildPreChatSurveyDialog(survey = dialog.survey) } - setLogo(images.logo) - supportActionBar?.setBackgroundDrawable(ColorDrawable(ChatTheme.colors.primary.toArgb())) } } } - @Composable - private fun BuildEditCustomValues() { - EditCustomValuesDialog( - title = stringResource(string.edit_custom_field_title), - fields = chatViewModel.preChatSurvey?.fields - .orEmpty() - .mergeWithCustomField( - chatViewModel.customValues - ), - onCancel = chatViewModel::cancelEditingCustomValues, - onConfirm = chatViewModel::confirmEditingCustomValues - ) - } - - @Composable - private fun EditThreadName() { - EditThreadNameDialog( - threadName = chatViewModel.selectedThreadName ?: "", - onCancel = chatViewModel::dismissDialog, - onAccept = chatViewModel::confirmEditThreadName - ) - } - @Composable private fun BuildPreChatSurveyDialog(survey: PreChatSurvey) { PreChatSurveyDialog( @@ -268,7 +268,11 @@ class ChatActivity : override fun onResume() { super.onResume() - chatViewModel.reportOnResume() + lifecycleScope.launch { + chatStateViewModel.state.filter { it == CONNECTED }.firstOrNull()?.also { + chatViewModel.reportOnResume() + } + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -277,7 +281,7 @@ class ChatActivity : } override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - if (menu != null) { + if (menu != null && chatStateViewModel.state.value == CONNECTED) { val navController = findNavController(R.id.nav_host_fragment) val isInChat = navController.currentDestination?.id == R.id.chatThreadFragment val isMultiThread = chatViewModel.isMultiThreadEnabled @@ -292,25 +296,32 @@ class ChatActivity : return super.onPrepareOptionsMenu(menu) } - override fun onMenuItemClick(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.action_custom_values -> editCustomValues() - R.id.action_thread_name -> showEditThreadNameDialog() - } - return true - } - - private suspend fun registerChatStateUiHandler() { + private suspend fun handleChatState() { chatStateViewModel.state.collect { state: ChatState -> when (state) { - INITIAL -> Ignored + // 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, - string.chat_state_connecting, + getString(string.chat_state_connecting), Snackbar.LENGTH_INDEFINITE ).setAction(string.chat_state_connecting_action_cancel) { - finish() // Dirty hack - refactor together with DI + finish() }.apply(Snackbar::show) CONNECTED -> chatStateSnackbar = Snackbar.make( @@ -319,34 +330,79 @@ class ChatActivity : Snackbar.LENGTH_SHORT ).apply(Snackbar::show) + READY -> chatStateSnackbar = Snackbar.make( + binding.root, + "SDK ready", + Snackbar.LENGTH_SHORT + ).apply(Snackbar::show) + CONNECTION_LOST -> chatStateSnackbar = Snackbar.make( binding.root, string.chat_state_connection_lost, Snackbar.LENGTH_INDEFINITE ).setAction(string.chat_state_connection_lost_action_reconnect) { - chatViewModel.reconnect() - }.apply(Snackbar::show) - - CONNECTION_CLOSED -> chatStateSnackbar = Snackbar.make( - binding.root, - string.chat_state_connection_closed, - Snackbar.LENGTH_INDEFINITE - ).setAction(string.chat_state_connection_closed_action_restart) { - finish() + chatViewModel.connect() }.apply(Snackbar::show) } } } - private fun showEditThreadNameDialog() { - chatViewModel.editThreadName() + private fun showOnThreadCreationFailure(state: SingleThreadCreationFailed) { + showAlert(describe(state.failure), onClick = chatViewModel::dismissThreadCreationFailure) } - private fun editCustomValues() { - chatViewModel.startEditingCustomValues() + private suspend fun Intent.handleDeeplink() { + val data = data ?: return + withContext(Dispatchers.Default) { + data + .parseThreadDeeplink() + .mapCatching { chatThreadsViewModel.selectThreadById(it) } + } } - private fun showOnThreadCreationFailure(state: State.SingleThreadCreationFailed) { - showAlert(describe(state.failure), onClick = chatViewModel::dismissThreadCreationFailure) + 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) + } + } + + /* + * 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) + } + } + + /** + * Start the [ChatActivity] from a given source activity. + * + * @param from Activity to use as a base for the new [ChatActivity]. + */ + fun startChat(from: Activity) { + from.startActivity(Intent(from, ChatActivity::class.java)) + from.overrideOpenAnimation(anim.present_chat, anim.present_host) + } } } + +private fun Uri.parseThreadDeeplink(): Result<UUID> = runCatching { + val threadIdString = getQueryParameter("idOnExternalPlatform") + require(!threadIdString.isNullOrEmpty()) { "Invalid threadId in $this" } + UUID.fromString(threadIdString) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/ImagePreviewActivity.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/ImagePreviewActivity.kt deleted file mode 100644 index a7c3f314..00000000 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/ImagePreviewActivity.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2021-2023. 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.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.navigation.navArgs -import coil.compose.AsyncImage -import com.nice.cxonechat.ui.R.string -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 net.engawapg.lib.zoomable.rememberZoomState -import net.engawapg.lib.zoomable.zoomable - -/** - * Activity to preview images in chat transcript. - */ -class ImagePreviewActivity : ComponentActivity() { - private val imageUrl: String by lazy { navArgs<ImagePreviewActivityArgs>().value.imageUrl } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - ChatTheme { - ChatTheme.Scaffold( - topBar = { - ChatTheme.TopBar( - title = stringResource(string.image_preview_title) - ) - } - ) { paddingValues -> - ZoomableImage(paddingValues) - } - } - } - } - - @Composable - private fun ZoomableImage(paddingValues: PaddingValues) { - AsyncImage( - model = imageUrl, - contentDescription = null, - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .zoomable(rememberZoomState()) - ) - } -} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PushListenerService.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PushListenerService.kt index 671afa9f..f84a4522 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PushListenerService.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/PushListenerService.kt @@ -22,42 +22,47 @@ import android.content.Intent import android.media.RingtoneManager import android.net.Uri import android.os.Build -import android.util.Log import androidx.core.app.NotificationCompat.Builder import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.nice.cxonechat.ChatInstanceProvider +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 com.nice.cxonechat.ui.R.string import com.nice.cxonechat.ui.domain.PushMessage import com.nice.cxonechat.ui.domain.PushMessageParser -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject +import org.koin.android.ext.android.get +import org.koin.android.ext.android.inject +import org.koin.core.qualifier.named -@AndroidEntryPoint internal class PushListenerService : FirebaseMessagingService() { - private var chatProvider = ChatInstanceProvider.get() - @Inject - lateinit var parser: PushMessageParser + private val chatProvider: ChatInstanceProvider by inject() - override fun onNewToken(token: String) { + private val parser: PushMessageParser by inject() + + private val logger by lazy { LoggerScope(TAG, get(named(UiModule.loggerName))) } + + override fun onNewToken(token: String) = logger.scope("onNewToken") { super.onNewToken(token) val chat = chatProvider.chat if (chat == null) { - Log.v(TAG, "No chat instance present, token not passed") - return + verbose("No chat instance present, token not passed") + return@scope } chat.setDeviceToken(token) - Log.d(TAG, "Registering push notifications token: $token") + debug("Registering push notifications token: $token") } - override fun onMessageReceived(remoteMessage: RemoteMessage) { + override fun onMessageReceived(remoteMessage: RemoteMessage): Unit = logger.scope("onMessageReceived") { super.onMessageReceived(remoteMessage) - Log.d(TAG, "Received push message: " + remoteMessage.data) + debug("Received push message: " + remoteMessage.data) if (isAppInForeground()) { - Log.d(TAG, "Application is in foreground, discarding push message") + debug("Application is in foreground, discarding push message") } else { val pushMessage = parser.parse(remoteMessage.data) sendNotification(pushMessage) @@ -76,7 +81,7 @@ internal class PushListenerService : FirebaseMessagingService() { .setSmallIcon(iconResId) .setContentTitle(message.title) .setContentText(message.message) - .setAutoCancel(false) + .setAutoCancel(true) .setSound(defaultSoundUri) .setPriority(2) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/UiModule.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/UiModule.kt new file mode 100644 index 00000000..f2ba680f --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/UiModule.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2021-2023. 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 com.nice.cxonechat.ChatInstanceProvider +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerNoop +import com.nice.cxonechat.ui.data.PinpointPushMessageParser +import com.nice.cxonechat.ui.domain.PushMessageParser +import com.nice.cxonechat.utilities.TaggingSocketFactory +import okhttp3.OkHttpClient +import org.koin.core.KoinApplication +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Module +import org.koin.core.annotation.Singleton +import org.koin.core.module.dsl.factoryOf +import org.koin.core.qualifier.named +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.ksp.generated.module + +@Module +@ComponentScan("com.nice.cxonechat.ui") +@Suppress("UndocumentedPublicClass") +class UiModule internal constructor() { + @Factory + internal fun produceChatInstanceProvider() = ChatInstanceProvider.get() + + @Factory + internal fun produceChat() = requireNotNull(produceChatInstanceProvider().chat) + + @Singleton + internal fun produceOkHttpClient() = OkHttpClient.Builder() + .socketFactory(TaggingSocketFactory) + .build() + + companion object { + internal const val loggerName = "com.nice.cxonechat.ui.logger" + + /** + * Initialize the Ui Module. + * + * Invoked as: + * + * ``` + * startKoin { + * UiModule.setup() + * } + * ``` + * + * @note Must be called before [ChatActivity] is created. + * + * @receiver KoinApplication instance to configure. + * @param logger Logger to use if logging is desired. + */ + fun KoinApplication.chatUiModule(logger: Logger = LoggerNoop) { + modules( + listOf( + UiModule().module, + module { + factoryOf(::PinpointPushMessageParser).bind(PushMessageParser::class) + factory(named(loggerName)) { logger } + } + ) + ) + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/VideoPreviewActivity.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/VideoPreviewActivity.kt deleted file mode 100644 index 20d921ef..00000000 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/VideoPreviewActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2021-2023. 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 android.os.Bundle -import android.widget.MediaController -import androidx.appcompat.app.AppCompatActivity -import androidx.navigation.navArgs -import com.nice.cxonechat.ui.databinding.ActivityVideoPreviewBinding - -/** - * Activity to preview videos from chat transcript. - */ -class VideoPreviewActivity : AppCompatActivity() { - @Suppress("LateinitUsage") - private lateinit var binding: ActivityVideoPreviewBinding - private val videoUrl: String by lazy { navArgs<VideoPreviewActivityArgs>().value.videoUrl } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityVideoPreviewBinding.inflate(layoutInflater) - setContentView(binding.root) - - with(binding) { - val mediaController = MediaController(this@VideoPreviewActivity) - mediaController.setAnchorView(videoView) - - videoView.setVideoURI(Uri.parse(videoUrl)) - videoView.setMediaController(mediaController) - videoView.start() - } - } -} 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 new file mode 100644 index 00000000..bb68a818 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AttachmentIcon.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2021-2023. 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 android.content.Context +import android.net.Uri +import android.view.ViewGroup.LayoutParams +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +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.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import com.nice.cxonechat.message.Attachment +import com.nice.cxonechat.ui.composable.generic.PresetAsyncImage +import com.nice.cxonechat.ui.composable.generic.buildProgressivePlayerForUri +import com.nice.cxonechat.ui.composable.generic.forwardingPainter +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.composable.theme.ChatTheme.space +import com.nice.cxonechat.ui.composable.theme.SelectionFrame +import com.nice.cxonechat.ui.util.contentDescription + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun AttachmentIcon( + attachment: Attachment, + modifier: Modifier = Modifier, + selected: Boolean = false, + onClick: (Attachment) -> Unit, + onLongClick: (Attachment) -> Unit, +) { + ChatTheme.SelectionFrame( + modifier = modifier + .combinedClickable( + onClick = { onClick(attachment) }, + onLongClick = { onLongClick(attachment) } + ), + selected = selected, + ) { + val mimeType = attachment.mimeType + + when { + mimeType == null -> PlaceholderIcon(attachment) + mimeType.startsWith("image/") -> ImageIcon(attachment) + mimeType.startsWith("video/") -> VideoIcon(attachment) + mimeType.startsWith("audio/") -> AudioIcon(attachment) + else -> PlaceholderIcon(attachment) + } + } +} + +@Composable +private fun PlaceholderIcon(attachment: Attachment) { + Image( + painter = forwardingPainter( + rememberVectorPainter(image = Outlined.FilePresent), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ), + contentDescription = attachment.contentDescription, + modifier = Modifier.padding(space.small) + ) +} + +@Composable +private fun ImageIcon(attachment: Attachment) { + PresetAsyncImage( + model = attachment.url, + contentDescription = attachment.contentDescription, + contentScale = ContentScale.Crop, + ) +} + +@Composable +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +private fun VideoIcon(attachment: Attachment) { + Box { + if (LocalInspectionMode.current) { + // ExoPlayer is not working in Preview mode + Image( + painter = forwardingPainter( + rememberVectorPainter(image = Outlined.Videocam), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ), + contentDescription = attachment.contentDescription, + modifier = Modifier.padding(space.small).align(Alignment.Center), + ) + } else { + val context = LocalContext.current + val background = MaterialTheme.colors.surface.toArgb() + + val exoPlayer = remember { + buildProgressivePlayerForUri(context, Uri.parse(attachment.url)).apply { + playWhenReady = false + seekTo(0) + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + repeatMode = Player.REPEAT_MODE_OFF + } + } + AndroidView( + factory = { viewContext -> + playerFactory(viewContext, exoPlayer, background) + }, + onRelease = { playerView -> playerView.player?.release() }, + update = { playerView -> playerView.updatePlayer(exoPlayer) }, + ) + } + Image( + imageVector = Outlined.PlayArrow, + contentDescription = attachment.contentDescription, + modifier = Modifier.padding(space.small).align(Alignment.Center).fillMaxSize(), + ) + } +} + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +private fun playerFactory(viewContext: Context, exoPlayer: ExoPlayer, @ColorInt background: Int) = + PlayerView(viewContext).apply { + controllerAutoShow = false + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL + player = exoPlayer + layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + setShutterBackgroundColor(background) + } + +private fun PlayerView.updatePlayer(exoPlayer: ExoPlayer) { + val currentPlayer = player + if (currentPlayer != exoPlayer) { + currentPlayer?.release() + player = exoPlayer + } +} + +@Composable +private fun AudioIcon(attachment: Attachment) { + Image( + forwardingPainter( + painter = rememberVectorPainter(image = Outlined.Mic), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ), + contentDescription = attachment.contentDescription, + modifier = Modifier.padding(space.small) + ) +} + +@Preview +@Composable +private fun PreviewAttachmentIcon( + @PreviewParameter(AttachmentProvider::class) attachment: Attachment +) { + ChatTheme { + AttachmentIcon( + attachment = attachment, + modifier = Modifier.size(48.dp), + onClick = {}, + onLongClick = {} + ) + } +} 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 72f7b364..57b9865c 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 @@ -15,162 +15,128 @@ package com.nice.cxonechat.ui.composable.conversation -import android.net.Uri -import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Surface +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.Text -import androidx.compose.material.icons.Icons.Outlined -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.MessageDirection.ToClient -import com.nice.cxonechat.ui.composable.conversation.model.Message.Attachment -import com.nice.cxonechat.ui.composable.generic.AudioPlayer -import com.nice.cxonechat.ui.composable.generic.PresetAsyncImage -import com.nice.cxonechat.ui.composable.generic.VideoPlayer -import com.nice.cxonechat.ui.composable.generic.forwardingPainter +import com.nice.cxonechat.ui.R.string +import com.nice.cxonechat.ui.composable.conversation.model.Message.WithAttachments 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.space -import com.nice.cxonechat.ui.composable.theme.LocalSpace -import com.nice.cxonechat.message.Attachment as SdkAttachment +import com.nice.cxonechat.ui.composable.theme.SelectionFrame +import java.lang.Integer.min @Composable -internal fun AttachmentMessage(message: Attachment, modifier: Modifier) { - val mimeType = message.mimeType - when { - mimeType == null -> PlaceholderContent( - message = message, - placeholder = rememberVectorPainter(image = Outlined.ErrorOutline), - modifier = modifier, - colorFilter = ColorFilter.tint(ChatTheme.colors.error), - ) - - mimeType.startsWith("video/") -> VideoAttachmentContent(message, modifier) - mimeType.startsWith("image/") -> ImageAttachmentContent(message, modifier) - mimeType.startsWith("audio/") -> AudioAttachmentContent(message, modifier) - else -> PlaceholderContent(message, rememberVectorPainter(image = Outlined.Description), modifier) - } -} +internal fun AttachmentMessage( + message: WithAttachments, + modifier: Modifier, + onAttachmentClicked: (Attachment) -> Unit, + onMoreClicked: (List<Attachment>, String) -> Unit, + onShare: (Collection<Attachment>) -> Unit, +) { + val attachments = message.attachments.toList() + val iconCount = space.smallAttachmentCount -@Composable -private fun AttachmentContentDescription(message: Attachment, modifier: Modifier = Modifier) { Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally + modifier = modifier.padding(0.dp), + verticalArrangement = Arrangement.spacedBy(space.small) ) { - if (message.name.isNotBlank()) { - Text( - text = message.name, - style = ChatTheme.chatTypography.chatAttachmentCaption, - ) - } - if (message.text.isNotBlank()) { + if (attachments.isEmpty()) { + // This really can't happen since the decision to get to AttachmentMessage + // was predicated on attachments.count > 0 Text( - text = message.text, - style = ChatTheme.chatTypography.chatAttachmentMessage, + text = message.text.ifBlank { "Missing attachments in attachment message." }, + style = ChatTheme.chatTypography.chatMessage, ) + } else { + if (message.text.isNotBlank()) { + Text(message.text, style = ChatTheme.chatTypography.chatMessage) + } + + LazyRow(horizontalArrangement = Arrangement.spacedBy(space.small)) { + items( + min(iconCount, attachments.count()), + key = { attachments[it].url } + ) { index -> + AttachmentItem( + index = index, + message = message.text, + attachments = attachments, + onAttachmentClicked = onAttachmentClicked, + onMoreClicked = onMoreClicked, + onShare = onShare + ) + } + } } } } @Composable -private fun PlaceholderContent( - message: Attachment, - placeholder: Painter, - modifier: Modifier = Modifier, - colorFilter: ColorFilter = ColorFilter.tint(LocalContentColor.current), +private fun AttachmentItem( + index: Int, + message: String, + attachments: List<Attachment>, + onAttachmentClicked: (Attachment) -> Unit, + onMoreClicked: (List<Attachment>, String) -> Unit, + onShare: (Collection<Attachment>) -> Unit, ) { - val placeholderPainter = forwardingPainter( - painter = placeholder, - colorFilter = colorFilter - ) - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = placeholderPainter, - contentDescription = message.name, - modifier = Modifier.size(LocalSpace.current.clickableSize) + val count = attachments.size + val iconCount = space.smallAttachmentCount + + if (index == iconCount - 1 && count > iconCount) { + MoreIcon( + count = count - iconCount + 1, + onClicked = { onMoreClicked(attachments, message) }, ) - AttachmentContentDescription(message) - } -} + } else { + val attachment = attachments[index] -@Composable -private fun ImageAttachmentContent( - message: Attachment, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - PresetAsyncImage( - model = message.originalUrl.ifBlank { null }, - contentDescription = message.name, - modifier = Modifier - .defaultMinSize(LocalSpace.current.clickableSize, LocalSpace.current.clickableSize) - .clip(RoundedCornerShape(space.large)), + AttachmentIcon( + attachment = attachment, + modifier = Modifier.size(space.smallAttachmentSize), + onClick = onAttachmentClicked, + onLongClick = { + if (attachments.size > 1) { + onMoreClicked(attachments, message) + } else { + onShare(listOf(it)) + } + }, ) - AttachmentContentDescription(message = message) } } @Composable -private fun VideoAttachmentContent( - message: Attachment, +private fun MoreIcon( + count: Int, modifier: Modifier = Modifier, + onClicked: () -> Unit, ) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, + ChatTheme.SelectionFrame( + modifier = Modifier + .size(space.smallAttachmentSize) + .then(modifier) + .clickable(onClick = onClicked) ) { - VideoPlayer(uri = Uri.parse(message.originalUrl), modifier = Modifier.clip(chatShapes.chatVideoPlayer)) - AttachmentContentDescription(message = message, Modifier.padding(top = space.small)) - } -} - -@Composable -private fun AudioAttachmentContent(message: Attachment, modifier: Modifier) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AudioPlayer(uri = Uri.parse(message.originalUrl), modifier = Modifier.clip(chatShapes.chatAudioPlayer)) - AttachmentContentDescription(message = message, Modifier.padding(top = space.small)) - } -} - -@Preview -@Composable -private fun PreviewPlaceholder() { - ChatTheme { - Surface { - AttachmentMessage( - message = Attachment( - message = previewTextMessage("Example document", direction = ToClient), - attachment = object : SdkAttachment { - override val url: String = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" - override val friendlyName: String = "dummy.pdf" - override val mimeType: String? = null - } - ), - modifier = Modifier + Box(contentAlignment = Alignment.Center) { + Text( + stringResource(string.extra_attachments_count, count), + style = ChatTheme.typography.subtitle1 ) } } @@ -178,51 +144,31 @@ private fun PreviewPlaceholder() { @Preview @Composable -private fun PreviewContentAttachmentImage() { - PreviewMessageItemBase { - MessageItem( - message = Attachment( - message = previewTextMessage("Preview image"), - attachment = object : SdkAttachment { - override val url: String = "https://http.cat/203" - override val friendlyName: String = "cat_no_content.jpeg" - override val mimeType: String = "image/jpeg" - } - ), - ) +private fun PreviewMoreIcon() { + ChatTheme { + MoreIcon(count = 4, onClicked = {}) } } -@Preview -@Composable -private fun PreviewMessageAttachmentVideo() { - PreviewMessageItemBase { - MessageItem( - message = Attachment( - message = previewTextMessage("Preview video", direction = ToClient), - attachment = object : SdkAttachment { - override val url: String = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" - override val friendlyName: String = "example.webm" - override val mimeType: String = "video/mp4" - } - ), - ) - } -} +private data class CountProvider( + override val values: Sequence<Int> = (0..5).asSequence() +) : PreviewParameterProvider<Int> @Preview @Composable -private fun PreviewMessageAttachmentPdf() { - PreviewMessageItemBase { - MessageItem( - message = Attachment( - message = previewTextMessage("Example document", direction = ToClient), - attachment = object : SdkAttachment { - override val url: String = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" - override val friendlyName: String = "dummy.pdf" - override val mimeType: String = "application/pdf" - } - ), +private fun PreviewAttachmentMessage( + @PreviewParameter(CountProvider::class) count: Int +) { + val message = WithAttachments( + message = previewTextMessage( + "Preview video", + direction = ToClient, + attachments = PreviewAttachments.with(count) ) - } + ) + + PreviewMessageItemBase( + message = message, + showSender = true + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AudioPlayerDialog.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AudioPlayerDialog.kt new file mode 100644 index 00000000..2d20cd3e --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/AudioPlayerDialog.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2021-2023. 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 android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.nice.cxonechat.ui.composable.generic.AudioPlayer +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.composable.theme.TopBar + +/** + * Present an audio player pop dialog. + * + * @param url URL of audio to play. + * @param title Title to display + * @param onCancel Action when user has cancelled the dialog via back button or a tap outside the dialog. + */ +@Composable +fun AudioPlayerDialog( + url: String, + title: String?, + onCancel: () -> Unit +) { + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + ) + ) { + Column { + title?.let { + ChatTheme.TopBar(title = it) + } + AudioPlayer(uri = Uri.parse(url)) + } + } +} + +@Preview +@Composable +private fun PreviewTitlelessAudioPlayer() { + ChatTheme { + AudioPlayerDialog(url = "https://some.url", null) {} + } +} + +@Preview +@Composable +private fun PreviewTitledAudioPlayer() { + ChatTheme { + AudioPlayerDialog(url = "https://some.url", "Title") {} + } +} 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 25608e17..e2f6db83 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 @@ -23,23 +23,31 @@ 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.Surface import androidx.compose.material.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.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.nice.cxonechat.message.Message import com.nice.cxonechat.message.MessageDirection.ToClient +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.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.delay import kotlinx.coroutines.launch import java.util.Calendar import java.util.Date @@ -51,6 +59,8 @@ import java.util.Date * @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 modifier Optional [Modifier] for [Scaffold] surrounding the conversation view. */ @Composable @@ -58,6 +68,8 @@ internal fun ChatConversation( conversationState: ConversationUiState, audioRecordingState: AudioRecordingUiState, onAttachmentTypeSelection: (mimeType: String) -> Unit, + onEditThreadName: () -> Unit, + onEditThreadValues: () -> Unit, modifier: Modifier = Modifier, ) { val scrollState = rememberLazyListState() @@ -65,31 +77,78 @@ internal fun ChatConversation( val context = LocalContext.current val messages = conversationState.messages(context).collectAsState(initial = emptyList()).value - Column( - modifier.fillMaxSize(), + LaunchedEffect(messages) { + if (scrollState.firstVisibleItemIndex <= 1) { // Only autoscroll if user is on last message + delay(250) + scrollState.scrollToItem(0) + } + } + + ChatTheme.Scaffold( + topBar = { + ChatThreadTopBar( + conversationState = conversationState, + onEditThreadName = onEditThreadName, + onEditThreadValues = onEditThreadValues, + ) + } ) { - MessageListView( - messages, - conversation = conversationState, - scrollState = scrollState, - modifier = Modifier.weight(1f) - ) - UserInput( - conversationUiState = conversationState, - resetScroll = { - scope.launch { - scrollState.scrollToItem(0) - } - }, - modifier = Modifier - .navigationBarsPadding() - .imePadding(), - audioRecordingUiState = audioRecordingState, - onAttachmentTypeSelection = onAttachmentTypeSelection, - ) + Column( + modifier.fillMaxSize(), + ) { + MessageListView( + messages, + conversation = conversationState, + scrollState = scrollState, + modifier = Modifier.weight(1f) + ) + UserInput( + conversationUiState = conversationState, + resetScroll = { + scope.launch { + scrollState.scrollToItem(0) + } + }, + modifier = Modifier + .navigationBarsPadding() + .imePadding(), + audioRecordingUiState = audioRecordingState, + onAttachmentTypeSelection = onAttachmentTypeSelection, + ) + } } } +@Composable +private fun ChatThreadTopBar( + conversationState: ConversationUiState, + onEditThreadName: () -> Unit, + onEditThreadValues: () -> 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) + ) + } + } + } + ) +} + @Composable internal fun MessageListView( messages: List<Section>, @@ -99,15 +158,17 @@ internal fun MessageListView( ) { val isTyping = conversation.typingIndicator.collectAsState(initial = false).value val canLoadMore = conversation.canLoadMore.collectAsState().value + Surface(modifier) { Column { Messages( scrollState = scrollState, groupedMessages = messages, - onClick = conversation.onClick, - onMessageLongClick = conversation.onLongClick, loadMore = conversation.loadMore, - canLoadMore = canLoadMore + canLoadMore = canLoadMore, + onAttachmentClicked = conversation.onAttachmentClicked, + onMoreClicked = conversation.onMoreClicked, + onShare = conversation.onShare, ) TypingIndicator(isTyping) } @@ -138,6 +199,7 @@ private fun PreviewChat() { previewTextMessage("Hello again", createdAt = firstDate), previewTextMessage("Is anyone there?", createdAt = firstDate), previewTextMessage("Hi, how are you?", direction = ToClient, createdAt = secondDate), + previewTextMessage("Hi, how are you, again?", direction = ToClient, createdAt = secondDate), ).sortedByDescending(Message::createdAt) val conversation = previewUiState(messages) val context = LocalContext.current @@ -157,6 +219,8 @@ private fun PreviewChatMessageInput() { conversationState = previewUiState(messages), audioRecordingState = previewAudioState(), onAttachmentTypeSelection = {}, + onEditThreadName = {}, + onEditThreadValues = {}, ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ContentType.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ContentType.kt index 8292cd90..ec6c86e2 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ContentType.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/ContentType.kt @@ -24,18 +24,18 @@ import com.nice.cxonechat.ui.composable.conversation.ContentType.RICH_LINK import com.nice.cxonechat.ui.composable.conversation.ContentType.TEXT import com.nice.cxonechat.ui.composable.conversation.ContentType.UNSUPPORTED import com.nice.cxonechat.ui.composable.conversation.model.Message -import com.nice.cxonechat.ui.composable.conversation.model.Message.Attachment import com.nice.cxonechat.ui.composable.conversation.model.Message.ListPicker import com.nice.cxonechat.ui.composable.conversation.model.Message.Plugin import com.nice.cxonechat.ui.composable.conversation.model.Message.QuickReply import com.nice.cxonechat.ui.composable.conversation.model.Message.RichLink import com.nice.cxonechat.ui.composable.conversation.model.Message.Text import com.nice.cxonechat.ui.composable.conversation.model.Message.Unsupported +import com.nice.cxonechat.ui.composable.conversation.model.Message.WithAttachments @Stable internal val Message.contentType: ContentType get() = when (this) { - is Attachment -> ATTACHMENT + is WithAttachments -> ATTACHMENT is Text -> TEXT is ListPicker -> LIST_PICKER is RichLink -> RICH_LINK 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 be99415c..b28b44fa 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 @@ -17,7 +17,6 @@ package com.nice.cxonechat.ui.composable.conversation import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -29,6 +28,8 @@ import com.nice.cxonechat.message.MessageAuthor import com.nice.cxonechat.message.MessageDirection import com.nice.cxonechat.message.MessageDirection.ToClient import com.nice.cxonechat.message.MessageMetadata +import com.nice.cxonechat.message.MessageStatus +import com.nice.cxonechat.message.MessageStatus.SENT import com.nice.cxonechat.ui.composable.conversation.model.Message.ListPicker import com.nice.cxonechat.ui.composable.theme.ChatTheme.chatTypography import com.nice.cxonechat.ui.composable.theme.ChatTheme.space @@ -54,35 +55,35 @@ internal fun ListPickerMessage(message: ListPicker, modifier: Modifier = Modifie @Preview @Composable private fun ListPickerMessagePreview() { - PreviewMessageItemBase { - MessageItem( - message = ListPicker( - message = object : SdkListPicker() { - override val title: String = "This is a list picker card" - override val text: String = "We have provided list of random options, you should select one." - override val actions: Iterable<SdkAction> = listOf( - PreviewReplyButton("Some text"), - PreviewReplyButton("Random cat", "https://http.cat/203") - ) - override val id: UUID = UUID.randomUUID() - override val threadId: UUID = UUID.randomUUID() - override val createdAt: Date = Date() - override val direction: MessageDirection = ToClient - override val metadata: MessageMetadata = object : MessageMetadata { - override val readAt: Date? = null - } - override val author: MessageAuthor = object : MessageAuthor() { - override val id: String = "" - override val firstName: String = "firstname" - override val lastName: String = "lastname" - override val imageUrl: String? = null - } - override val attachments: Iterable<Attachment> = emptyList() - override val fallbackText: String = "Fallback" - }, - sendMessage = {} - ), - modifier = Modifier.padding(space.large) - ) - } + PreviewMessageItemBase( + message = ListPicker( + message = object : SdkListPicker() { + override val title: String = "This is a list picker card" + override val text: String = "We have provided list of random options, you should select one." + override val actions: Iterable<SdkAction> = listOf( + PreviewReplyButton("Some text"), + PreviewReplyButton("Random cat", "https://http.cat/203") + ) + override val id: UUID = UUID.randomUUID() + override val threadId: UUID = UUID.randomUUID() + override val createdAt: Date = Date() + override val direction: MessageDirection = ToClient + override val metadata: MessageMetadata = object : MessageMetadata { + override val readAt: Date? = null + override val status: MessageStatus = SENT + override val seenAt: Date? = null + } + override val author: MessageAuthor = object : MessageAuthor() { + override val id: String = "" + override val firstName: String = "firstname" + override val lastName: String = "lastname" + override val imageUrl: String? = null + } + override val attachments: Iterable<Attachment> = emptyList() + override val fallbackText: String = "Fallback" + }, + sendMessage = {} + ), + showSender = true, + ) } 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 9f476ff9..6a93fb1f 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 @@ -16,7 +16,6 @@ package com.nice.cxonechat.ui.composable.conversation import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -29,18 +28,26 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.MessageDirection.ToAgent +import com.nice.cxonechat.message.MessageDirection.ToClient +import com.nice.cxonechat.message.MessageStatus.FAILED_TO_DELIVER +import com.nice.cxonechat.message.MessageStatus.READ +import com.nice.cxonechat.message.MessageStatus.SEEN +import com.nice.cxonechat.message.MessageStatus.SENDING +import com.nice.cxonechat.message.MessageStatus.SENT import com.nice.cxonechat.ui.R.string import com.nice.cxonechat.ui.composable.conversation.model.Message -import com.nice.cxonechat.ui.composable.conversation.model.Message.Attachment import com.nice.cxonechat.ui.composable.conversation.model.Message.ListPicker import com.nice.cxonechat.ui.composable.conversation.model.Message.Plugin import com.nice.cxonechat.ui.composable.conversation.model.Message.QuickReply import com.nice.cxonechat.ui.composable.conversation.model.Message.RichLink import com.nice.cxonechat.ui.composable.conversation.model.Message.Text import com.nice.cxonechat.ui.composable.conversation.model.Message.Unsupported +import com.nice.cxonechat.ui.composable.conversation.model.Message.WithAttachments import com.nice.cxonechat.ui.composable.theme.ChatTheme.chatColors import com.nice.cxonechat.ui.composable.theme.ChatTheme.chatShapes +import com.nice.cxonechat.ui.composable.theme.ChatTheme.chatTypography import com.nice.cxonechat.ui.composable.theme.ChatTheme.space import com.nice.cxonechat.ui.composable.theme.SmallSpacer @@ -48,42 +55,74 @@ import com.nice.cxonechat.ui.composable.theme.SmallSpacer @Composable internal fun LazyItemScope.MessageItem( message: Message, + showSender: Boolean, modifier: Modifier = Modifier, - onClick: (Message) -> Unit = {}, - onMessageLongClick: (Message) -> Unit = {}, + onAttachmentClicked: (Attachment) -> Unit, + onMoreClicked: (List<Attachment>, String) -> Unit, + onShare: (Collection<Attachment>) -> Unit, ) { - val alignment = if (message.direction == ToAgent) Alignment.End else Alignment.Start - val chatColor = if (message.direction == ToAgent) chatColors.customer else chatColors.agent - val shape = if (message.direction == ToAgent) chatShapes.bubbleShapeToAgent else chatShapes.bubbleShapeToClient + val toAgent = message.direction == ToAgent + val alignment = if (toAgent) Alignment.End else Alignment.Start + val chatColor = if (toAgent) chatColors.customer else chatColors.agent + val shape = if (toAgent) chatShapes.bubbleShapeToAgent else chatShapes.bubbleShapeToClient + val showAgentSender = showSender && !toAgent + Row( modifier = modifier .fillParentMaxWidth() .wrapContentWidth(align = alignment) .animateItemPlacement(), ) { - Surface( - color = chatColor.background, - contentColor = chatColor.foreground, - shape = shape, - ) { - MessageContent( - message = message, - modifier = Modifier - .weight(1f) - .combinedClickable( - onClick = { onClick(message) }, - onLongClick = { onMessageLongClick(message) }, - ), - ) + Column(horizontalAlignment = alignment) { + if (showAgentSender) { + Text( + message.sender?.ifBlank { null } ?: stringResource(id = string.default_agent_name), + style = chatTypography.chatAgentName, + ) + } + Surface( + color = chatColor.background, + contentColor = chatColor.foreground, + shape = shape, + ) { + MessageContent( + message = message, + modifier = Modifier + .weight(1f), + onAttachmentClicked = onAttachmentClicked, + onMoreClicked = onMoreClicked, + onShare = onShare, + ) + } + if (toAgent) { + MessageStatus(message) + } } } SmallSpacer() } +@Composable +private fun MessageStatus(message: Message) { + Text( + when (message.status) { + SENDING -> stringResource(string.status_sending) + SENT -> stringResource(string.status_sent) + FAILED_TO_DELIVER -> stringResource(string.status_failed) + SEEN -> stringResource(string.status_received) + READ -> stringResource(string.status_read) + }, + style = chatTypography.chatStatus, + ) +} + @Composable private fun MessageContent( message: Message, modifier: Modifier = Modifier, + onAttachmentClicked: (Attachment) -> Unit, + onMoreClicked: (List<Attachment>, String) -> Unit, + onShare: (Collection<Attachment>) -> Unit, ) = Column(modifier) { val padding = Modifier.padding(space.large) when (message) { @@ -92,8 +131,18 @@ private fun MessageContent( modifier = padding, ) - is Text -> Text(text = message.text, modifier = padding) - is Attachment -> AttachmentMessage(message, modifier = padding) + is Text -> Text( + text = message.text, + modifier = padding, + style = chatTypography.chatMessage + ) + is WithAttachments -> AttachmentMessage( + message, + modifier = padding, + onAttachmentClicked = onAttachmentClicked, + onMoreClicked = onMoreClicked, + onShare = onShare, + ) is ListPicker -> ListPickerMessage(message, modifier = padding) is RichLink -> RichLinkMessage(message = message, modifier = padding) is QuickReply -> QuickReplyMessage(message, modifier = padding) @@ -104,19 +153,17 @@ private fun MessageContent( @Preview @Composable private fun PreviewContentTextMessage() { - PreviewMessageItemBase { - MessageItem( - message = Text(previewTextMessage("Text message")), - ) - } + PreviewMessageItemBase( + message = Text(previewTextMessage("Text message", direction = ToClient)), + showSender = true, + ) } @Preview @Composable private fun PreviewContentUnsupported() { - PreviewMessageItemBase { - MessageItem( - message = Unsupported(previewTextMessage("Unused")), - ) - } + PreviewMessageItemBase( + message = Unsupported(previewTextMessage("Unused")), + showSender = true, + ) } 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 992d37cd..7308dee4 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 @@ -17,20 +17,26 @@ 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 import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.ui.R.string import com.nice.cxonechat.ui.composable.conversation.ContentType.DATE_HEADER import com.nice.cxonechat.ui.composable.conversation.ContentType.LOADING -import com.nice.cxonechat.ui.composable.conversation.model.Message import com.nice.cxonechat.ui.composable.conversation.model.Section +import com.nice.cxonechat.ui.composable.conversation.plugin.MessagePreviewProvider +import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.ChatTheme.space import com.nice.cxonechat.ui.util.isSameDay import java.util.Date @@ -40,10 +46,11 @@ import java.util.Date internal fun ColumnScope.Messages( scrollState: LazyListState, groupedMessages: List<Section>, - onClick: (Message) -> Unit, - onMessageLongClick: (Message) -> Unit, loadMore: () -> Unit, canLoadMore: Boolean, + onAttachmentClicked: (Attachment) -> Unit, + onMoreClicked: (List<Attachment>, String) -> Unit, + onShare: (Collection<Attachment>) -> Unit, ) { LazyColumn( reverseLayout = true, @@ -52,21 +59,29 @@ internal fun ColumnScope.Messages( modifier = Modifier .weight(1f) .fillMaxSize(), - contentPadding = PaddingValues(space.medium) + contentPadding = PaddingValues(space.medium), ) { groupedMessages.forEach { section -> - items(items = section.messages, key = Message::id, contentType = Message::contentType) { message -> + itemsIndexed( + items = section.messages, + key = { _, message -> message.id }, + contentType = { _, message -> message.contentType } + ) { i, message -> + val isLast = i == section.messages.lastIndex + val showSender = isLast || message.sender != section.messages[i + 1].sender MessageItem( message = message, - onClick = onClick, - onMessageLongClick = onMessageLongClick, + showSender = showSender, + onAttachmentClicked = onAttachmentClicked, + onMoreClicked = onMoreClicked, + onShare = onShare, ) } // Note that these will actually appear *above* the relevant messages because of `reverseLayout = true` when { // no header if only one day is displayed - groupedMessages.count() <= 1 -> Unit + groupedMessages.size <= 1 -> Unit // Display "Today" over today's messages section.createdAt.isSameDay(Date()) -> @@ -81,6 +96,7 @@ internal fun ColumnScope.Messages( } } } + if (canLoadMore) { item(contentType = LOADING) { LoadMore(loadMore) @@ -88,3 +104,30 @@ internal fun ColumnScope.Messages( } } } + +@Preview +@Composable +private fun MessagesPreview() { + val context = LocalContext.current + val section = MessagePreviewProvider() + .values + .toList() + .groupBy { message -> message.createdAtDate(context) } + .entries + .map(::Section) + val listState = rememberLazyListState() + + ChatTheme { + Column { + Messages( + scrollState = listState, + groupedMessages = section, + loadMore = {}, + canLoadMore = false, + onAttachmentClicked = {}, + onMoreClicked = { _, _ -> }, + onShare = {}, + ) + } + } +} 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 45902789..ab0e99f0 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 @@ -22,6 +22,7 @@ import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.nice.cxonechat.message.Action import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.Media @@ -31,12 +32,15 @@ import com.nice.cxonechat.message.MessageDirection import com.nice.cxonechat.message.MessageDirection.ToAgent import com.nice.cxonechat.message.MessageDirection.ToClient import com.nice.cxonechat.message.MessageMetadata +import com.nice.cxonechat.message.MessageStatus +import com.nice.cxonechat.message.MessageStatus.SENDING import com.nice.cxonechat.ui.composable.conversation.model.ConversationUiState import com.nice.cxonechat.ui.composable.theme.ChatTheme import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import java.util.Date import java.util.UUID +import com.nice.cxonechat.ui.composable.conversation.model.Message as UiMessage // Shared preview methods @@ -45,6 +49,8 @@ internal fun previewTextMessage( text: String, direction: MessageDirection = ToAgent, createdAt: Date = Date(), + readAt: Date? = null, + attachments: Iterable<Attachment> = listOf<Attachment>().asIterable() ): Message.Text = PreviewTextMessage( direction = direction, @@ -61,17 +67,21 @@ internal fun previewTextMessage( }, text = text, createdAt = createdAt, + attachments = attachments, + metadata = PreviewMetadata( + readAt = readAt + ) ) @Stable internal data class PreviewTextMessage( override val direction: MessageDirection, - override val author: MessageAuthor, + override val author: MessageAuthor?, override val text: String, override val fallbackText: String? = null, override val id: UUID = UUID.randomUUID(), override val threadId: UUID = UUID.randomUUID(), - override val metadata: MessageMetadata = PreviewMetadata, + override val metadata: MessageMetadata = PreviewMetadata(), override val createdAt: Date = Date(), override val attachments: Iterable<Attachment> = emptyList(), ) : Message.Text() @@ -84,11 +94,11 @@ internal data class PreviewRichLinkMessage( val mediaUrl: String, val mediaMimeType: String, override val direction: MessageDirection = ToClient, - override val author: MessageAuthor = PreviewAuthor("FirstName", "LastName"), + override val author: MessageAuthor? = PreviewAuthor("FirstName", "LastName"), override val fallbackText: String? = null, override val id: UUID = UUID.randomUUID(), override val threadId: UUID = UUID.randomUUID(), - override val metadata: MessageMetadata = PreviewMetadata, + override val metadata: MessageMetadata = PreviewMetadata(), override val createdAt: Date = Date(), override val attachments: Iterable<Attachment> = emptyList(), override val media: Media = object : Media { @@ -138,14 +148,74 @@ internal data class PreviewReplyButton( override val description: String? = null } +internal object PreviewAttachments { + val image = object : Attachment { + override val url: String = "https://http.cat/203" + override val friendlyName: String = "cat_no_content.jpeg" + override val mimeType: String = "image/jpeg" + } + + val movie = object : Attachment { + override val url: String = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" + override val friendlyName: String = "example.webm" + override val mimeType: String = "video/mp4" + } + + val sound = object : Attachment { + override val url: String = "https://http.cat/204" + override val friendlyName: String = "cat_no_content.mp3" + override val mimeType: String = "audio/mp3" + } + + val pdf = object : Attachment { + override val url: String = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" + override val friendlyName: String = "dummy.pdf" + override val mimeType: String = "application/pdf" + } + + val choices = listOf(image, movie, sound, pdf) + + val attachments: Sequence<Attachment> + get() = generateSequence(choices[0]) { index -> + choices[(choices.indexOf(index) + 1) % choices.count()] + } + + fun with(count: Int): Iterable<Attachment> = attachments.take(count).toList() +} + +internal data class AttachmentProvider( + override val values: Sequence<Attachment> = PreviewAttachments.choices.asSequence(), +) : PreviewParameterProvider<Attachment> + @Immutable -internal object PreviewMetadata : MessageMetadata { - override val readAt: Date? = null +internal class PreviewMetadata( + override val seenAt: Date? = null, + override val readAt: Date? = null, + override val status: MessageStatus = SENDING, +) : MessageMetadata + +@Composable +internal fun PreviewMessageItemBase( + message: UiMessage, + showSender: Boolean = true, + onAttachmentClicked: (Attachment) -> Unit = {}, + onMoreClicked: (List<Attachment>, String) -> Unit = { _, _ -> }, + onShare: (Collection<Attachment>) -> Unit = {}, +) { + PreviewMessageItemBase { + MessageItem( + message = message, + showSender = showSender, + onAttachmentClicked = onAttachmentClicked, + onMoreClicked = onMoreClicked, + onShare = onShare + ) + } } @Composable internal fun PreviewMessageItemBase( - content: @Composable LazyItemScope.() -> Unit, + content: @Composable LazyItemScope.() -> Unit ) { ChatTheme { Surface { @@ -159,7 +229,11 @@ internal fun PreviewMessageItemBase( } @Stable -internal fun previewUiState(messages: List<Message> = emptyList()) = ConversationUiState( +internal fun previewUiState( + messages: List<Message> = emptyList(), + isMultiThreaded: Boolean = true, + hasQuestions: Boolean = true, +) = ConversationUiState( threadName = flowOf("Preview Thread"), sdkMessages = MutableStateFlow(messages), typingIndicator = flowOf(true), @@ -168,4 +242,9 @@ internal fun previewUiState(messages: List<Message> = emptyList()) = Conversatio canLoadMore = MutableStateFlow(true), onStartTyping = {}, onStopTyping = {}, + onAttachmentClicked = {}, + onMoreClicked = { _, _ -> }, + onShare = {}, + isMultiThreaded = isMultiThreaded, + hasQuestions = hasQuestions, ) 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 9a6f9a5b..f8f97888 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 @@ -17,7 +17,6 @@ package com.nice.cxonechat.ui.composable.conversation import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -64,27 +63,25 @@ internal fun QuickReplyMessage( @Preview @Composable private fun QuickReplyMessagePreview() { - PreviewMessageItemBase { - MessageItem( - message = QuickReply( - message = object : QuickReplies() { - override val title: String = "This is a quick reply card" - override val actions: Iterable<SdkAction> = listOf( - PreviewReplyButton("Some text"), - PreviewReplyButton("Random cat", "https://http.cat/203") - ) - override val id: UUID = UUID.randomUUID() - override val threadId: UUID = UUID.randomUUID() - override val createdAt: Date = Date() - override val direction: MessageDirection = ToClient - override val metadata: MessageMetadata = PreviewMetadata - override val author: MessageAuthor = PreviewAuthor("first", "last") - override val attachments: Iterable<Attachment> = emptyList() - override val fallbackText: String = "Fallback" - }, - sendMessage = {} - ), - modifier = Modifier.padding(space.large) - ) - } + PreviewMessageItemBase( + message = QuickReply( + message = object : QuickReplies() { + override val title: String = "This is a quick reply card" + override val actions: Iterable<SdkAction> = listOf( + PreviewReplyButton("Some text"), + PreviewReplyButton("Random cat", "https://http.cat/203") + ) + override val id: UUID = UUID.randomUUID() + override val threadId: UUID = UUID.randomUUID() + override val createdAt: Date = Date() + override val direction: MessageDirection = ToClient + override val metadata: MessageMetadata = PreviewMetadata() + override val author: MessageAuthor? = PreviewAuthor("first", "last") + override val attachments: Iterable<Attachment> = emptyList() + override val fallbackText: String = "Fallback" + }, + sendMessage = {} + ), + showSender = true, + ) } 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 fa84b860..c34ba2c3 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 @@ -52,17 +52,16 @@ internal fun RichLinkMessage(message: RichLink, modifier: Modifier = Modifier) { @Preview @Composable private fun PreviewMessageRichLink() { - PreviewMessageItemBase { - MessageItem( - message = RichLink( - message = PreviewRichLinkMessage( - title = "Random cat", - url = "https://nice.com", - mediaUrl = "https://thecatapi.com/api/images/get?format=src&type=jpeg", - mediaMimeType = "image/jpeg", - mediaFileName = "Preview Image" - ) + PreviewMessageItemBase( + message = RichLink( + message = PreviewRichLinkMessage( + title = "Random cat", + url = "https://nice.com", + mediaUrl = "https://thecatapi.com/api/images/get?format=src&type=jpeg", + mediaMimeType = "image/jpeg", + mediaFileName = "Preview Image" ) - ) - } + ), + showSender = true, + ) } 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 new file mode 100644 index 00000000..473f0b62 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/SelectAttachmentsDialog.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2021-2023. 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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.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.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.nice.cxonechat.message.Attachment +import com.nice.cxonechat.ui.R.string +import com.nice.cxonechat.ui.composable.generic.forwardingPainter +import com.nice.cxonechat.ui.composable.theme.BottomBar +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.composable.theme.ChatTheme.space +import com.nice.cxonechat.ui.composable.theme.TopBar +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Present a dialog to select and display or share from a list of [Attachment]. + * + * @param attachments Available attachments. + * @param title Title to display on the dialog. + * @param onAttachmentTapped Direct action on an attachment, typically preview a single attachment. + * @param onShare Share was selected for one or more attachments. Selected attachments are passed + * as the sole parameter. + * @param onCancel The dialog was cancelled via back button or a tap outside the dialog. + */ +@Composable +fun SelectAttachmentsDialog( + attachments: List<Attachment>, + title: String, + onAttachmentTapped: (Attachment) -> Unit, + onShare: (Collection<Attachment>) -> Unit, + onCancel: () -> Unit, +) { + val viewModel = remember { ViewModel(attachments, onAttachmentTapped, onShare) } + + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + ) + ) { + Surface { + Column { + val selecting = viewModel.selecting.collectAsState().value + + TopBar( + title.ifBlank { stringResource(string.attachments_title) }, + selecting, + viewModel::toggleSelecting + ) + + GridView(viewModel) + + if (selecting) { + BottomBar( + viewModel.selection.collectAsState().value, + onSelectAll = viewModel::selectAll, + onSelectNone = viewModel::selectNone, + onShare = viewModel.onShare + ) + } + } + } + } +} + +@Composable +private fun GridView( + viewModel: ViewModel, +) { + LazyVerticalGrid( + columns = GridCells.FixedSize(space.largeAttachmentSize), + contentPadding = PaddingValues(space.medium) + ) { + items( + viewModel.attachments.toList(), + key = { it.url } + ) { attachment -> + AttachmentIcon( + attachment = attachment, + modifier = Modifier + .size(space.largeAttachmentSize) + .padding(space.largeAttachmentPadding), + selected = viewModel.isSelected(attachment), + onClick = viewModel::onClick, + onLongClick = viewModel::onLongClick, + ) + } + } +} + +@Composable +private fun TopBar( + title: String, + selecting: Boolean, + toggleSelecting: () -> Unit +) { + val select = rememberVectorPainter( + if (selecting) Icons.Default.Deselect else Icons.Default.SelectAll + ) + + ChatTheme.TopBar( + title = title + ) { + IconButton(onClick = toggleSelecting) { + Icon( + painter = forwardingPainter( + select, + colorFilter = ColorFilter.tint(LocalContentColor.current) + ), + contentDescription = null + ) + } + } +} + +@Composable +private fun BottomBar( + selection: Collection<Attachment>, + onSelectAll: () -> Unit, + onSelectNone: () -> Unit, + onShare: (Collection<Attachment>) -> Unit, +) { + ChatTheme.BottomBar { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row { + TextButton(onClick = onSelectAll) { + Text(stringResource(string.select_all), color = ChatTheme.colors.onPrimary) + } + + TextButton(onClick = onSelectNone) { + Text(stringResource(string.select_none), color = ChatTheme.colors.onPrimary) + } + } + + if (selection.isEmpty()) { + Text(stringResource(string.select_items)) + } else { + Text("${selection.count()} items selected") + IconButton({ onShare(selection) }) { + Icon( + painter = forwardingPainter( + rememberVectorPainter(image = Outlined.Share), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ), + contentDescription = stringResource(string.share_content_description), + ) + } + } + } + } +} + +private class ViewModel( + val attachments: Collection<Attachment>, + private val onAttachmentTapped: (Attachment) -> Unit, + val onShare: (Collection<Attachment>) -> Unit, +) { + private val _selection = MutableStateFlow(setOf<Attachment>()) + val selection = _selection.asStateFlow() + + private val _selecting = MutableStateFlow(false) + val selecting = _selecting.asStateFlow() + + fun toggleSelecting(attachment: Attachment? = null) { + _selecting.value = !_selecting.value + + if (!selecting.value) { + _selection.value = setOf() + } else { + _selection.value = setOfNotNull(attachment) + } + } + + fun toggleSelected(attachment: Attachment) { + _selection.value = if (_selection.value.contains(attachment)) { + _selection.value - attachment + } else { + _selection.value + attachment + } + } + + fun isSelected(attachment: Attachment) = selection.value.contains(attachment) + + fun onClick(attachment: Attachment) { + if (selecting.value) { + toggleSelected(attachment) + } else { + onAttachmentTapped(attachment) + } + } + + fun onLongClick(attachment: Attachment) { + toggleSelecting(attachment) + } + + fun selectAll() { + _selection.value = attachments.toSet() + } + + fun selectNone() { + _selection.value = setOf() + } +} + +@Preview +@Composable +private fun TopBarPreview() { + ChatTheme { + TopBar(title = "title", selecting = false, toggleSelecting = {}) + } +} + +@Preview +@Composable +private fun BottomBarPreviewNone() { + ChatTheme { + BottomBar(selection = setOf(), onSelectAll = {}, onSelectNone = {}, onShare = {}) + } +} + +@Preview +@Composable +private fun BottomBarPreviewMore() { + ChatTheme { + BottomBar( + selection = AttachmentProvider().values.take(4).toSet(), + onSelectAll = {}, + onSelectNone = {}, + onShare = {} + ) + } +} + +@Preview +@Composable +private fun GridViewPreview() { + val attachments = remember { + AttachmentProvider().values.take(5).distinctBy(Attachment::url).toList() + } + + ChatTheme { + GridView( + ViewModel( + attachments = attachments, + onAttachmentTapped = {}, + onShare = {}, + ).apply { + toggleSelected(attachments.take(1).first()) + } + ) + } +} + +@Preview +@Composable +private fun SelectAttachmentsPreview() { + val attachments = remember { + AttachmentProvider().values.take(5).distinctBy(Attachment::url).toList() + } + + ChatTheme { + SelectAttachmentsDialog( + attachments = attachments, + title = "Title", + onAttachmentTapped = {}, + onShare = {}, + onCancel = {}, + ) + } +} 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 390e5a6e..a562ee88 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 @@ -68,6 +68,7 @@ import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -147,13 +148,19 @@ internal fun UserInput( } val sendMessage = { - conversationUiState.sendMessage(OutboundMessage(textState.text)) - // Reset text field and close keyboard - textState = TextFieldValue() - // Move scroll to bottom - resetScroll() - dismissKeyboard() - if (isTypingState) signalStoppedTyping() + // trim surrounding spaces + val text = textState.text.trim() + + // Don't send blank messages. + if (text.isNotEmpty()) { + conversationUiState.sendMessage(OutboundMessage(text)) + // Reset text field and close keyboard + textState = TextFieldValue() + // Move scroll to bottom + resetScroll() + dismissKeyboard() + if (isTypingState) signalStoppedTyping() + } } Surface(modifier = modifier) { @@ -272,6 +279,9 @@ private fun RowScope.UserInputText( onTextFieldFocused(state.isFocused) } lastFocusState = state.isFocused + } + .semantics { + testTag = "chat_text_field" }, keyboardOptions = KeyboardOptions( keyboardType = keyboardType, diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ButtonAction.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ButtonAction.kt index 629f5b6c..39e36f35 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ButtonAction.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/ButtonAction.kt @@ -41,17 +41,11 @@ internal fun compoundAction( internal fun sendMessageAction( sendMessage: (OutboundMessage) -> Unit, text: String, - postback: String? -): ButtonAction? = postback?.let { pb -> - { _ -> - sendMessage(OutboundMessage(text, pb)) - } + postback: String, +): ButtonAction = { _ -> + sendMessage(OutboundMessage(text, postback)) } -internal fun deepLinkAction(deepLink: String?): ButtonAction? = deepLink?.let { link -> - { context -> - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(link)) - ) - } +internal fun deepLinkAction(deepLink: String): ButtonAction = { context -> + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(deepLink))) } 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 e9568d1e..da3dae67 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 @@ -17,14 +17,15 @@ package com.nice.cxonechat.ui.composable.conversation.model import android.content.Context import androidx.compose.runtime.Stable +import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.OutboundMessage -import com.nice.cxonechat.ui.composable.conversation.model.Message.Attachment import com.nice.cxonechat.ui.composable.conversation.model.Message.ListPicker import com.nice.cxonechat.ui.composable.conversation.model.Message.Plugin import com.nice.cxonechat.ui.composable.conversation.model.Message.QuickReply import com.nice.cxonechat.ui.composable.conversation.model.Message.RichLink import com.nice.cxonechat.ui.composable.conversation.model.Message.Text import com.nice.cxonechat.ui.composable.conversation.model.Message.Unsupported +import com.nice.cxonechat.ui.composable.conversation.model.Message.WithAttachments import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -43,16 +44,17 @@ import com.nice.cxonechat.message.Message as SdkMessage * @property typingIndicator Flow indicating that the agent handling the conversation is typing. * @property sendMessage An action which will be invoked if the user wants to post a new message to the conversation, or * if he has interacted with an element which generates a message. - * @property onClick An action which handles when users performs clicks interaction with one concrete message in the - * conversation. - * @property onLongClick An action which handles performs long click interaction with one concrete message in the - * conversation. * @property loadMore An action which will be called when more messages can be displayed/loaded. * @property canLoadMore Flow indicating if there are more messages to load. - * @param onStartTyping An action which will be called when the user has started to type a text message. - * @param onStopTyping An action which will be called (with some delay) when the user has stopped typing a text message. + * @property onStartTyping An action which will be called when the user has started to type a text message. + * @property onStopTyping An action which will be called (with some delay) when the user has stopped typing a text message. + * @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. */ @Suppress( "LongParameterList", // POJO class @@ -63,13 +65,16 @@ internal data class ConversationUiState( private val sdkMessages: Flow<List<SdkMessage>>, internal val typingIndicator: Flow<Boolean>, internal val sendMessage: (OutboundMessage) -> Unit, - internal val onClick: (Message) -> Unit = {}, - internal val onLongClick: (Message) -> Unit = {}, internal val loadMore: () -> Unit, internal val canLoadMore: StateFlow<Boolean>, internal val onStartTyping: () -> Unit, internal val onStopTyping: () -> Unit, + internal val onAttachmentClicked: (Attachment) -> Unit, + internal val onMoreClicked: (List<Attachment>, String) -> Unit, + internal val onShare: (Collection<Attachment>) -> Unit, private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default, + internal val isMultiThreaded: Boolean, + internal val hasQuestions: Boolean, ) { @Stable internal fun messages(context: Context): Flow<List<Section>> = sdkMessages @@ -84,14 +89,12 @@ internal data class ConversationUiState( @Stable private fun SdkMessage.toUiMessage(): Message = when (this) { - is SdkMessage.Text -> { - val attachments = attachments.toList() - if (attachments.isEmpty()) { + is SdkMessage.Text -> + if (attachments.firstOrNull() == null) { Text(message = this) } else { - Attachment(this, attachments.first()) + WithAttachments(this) } - } is SdkMessage.RichLink -> RichLink(this) is SdkMessage.ListPicker -> ListPicker(this, sendMessage) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/Message.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/Message.kt index d75f266c..1c26300b 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/Message.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/Message.kt @@ -19,6 +19,7 @@ import android.content.Context import com.nice.cxonechat.message.Media import com.nice.cxonechat.message.Message.QuickReplies import com.nice.cxonechat.message.MessageDirection +import com.nice.cxonechat.message.MessageStatus import com.nice.cxonechat.message.OutboundMessage import com.nice.cxonechat.ui.util.toShortDateString import java.util.Date @@ -35,12 +36,18 @@ internal sealed class Message(original: SdkMessage) { /** See [SdkMessage.id]. */ val id = original.id + /** Friendly name of sender. See [SdkMessage.author] */ + val sender: String? = original.author?.name + /** See [com.nice.cxonechat.message.MessageAuthor.imageUrl]. */ - private val imageUrl: String? = original.author.imageUrl + private val imageUrl: String? = original.author?.imageUrl /** See [SdkMessage.createdAt]. */ val createdAt: Date = original.createdAt + /** The status of message. */ + val status: MessageStatus = original.metadata.status + /** See [SdkMessage.direction]. */ val direction: MessageDirection = original.direction @@ -55,23 +62,17 @@ internal sealed class Message(original: SdkMessage) { fun createdAtDate(context: Context): String = context.toShortDateString(createdAt) /** - * UI version of a [SdkMessage.Text] with a [SdkAttachment]. + * UI version of a [SdkMessage.Text] with one or more [SdkAttachment]. */ - data class Attachment( + data class WithAttachments( private val message: SdkMessage.Text, - private val attachment: SdkAttachment, ) : Message(message) { /** See [SdkMessage.Text.text]. */ val text: String = message.text - /** See [SdkAttachment.friendlyName]. */ - val name: String = attachment.friendlyName - - /** See [SdkAttachment.url]. */ - val originalUrl: String = attachment.url - - /** See [SdkAttachment.mimeType]. */ - val mimeType: String? = attachment.mimeType + /** Attachments to be presented to the user. */ + val attachments: Iterable<SdkAttachment> + get() = message.attachments } /** diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/PluginElement.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/PluginElement.kt index ff9ac223..31612cb0 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/PluginElement.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/model/PluginElement.kt @@ -16,9 +16,7 @@ package com.nice.cxonechat.ui.composable.conversation.model import com.nice.cxonechat.message.OutboundMessage -import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text.Format.Html -import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text.Format.Markdown -import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text.Format.Plain +import com.nice.cxonechat.message.TextFormat import okhttp3.internal.toImmutableMap import java.util.Date import javax.annotation.concurrent.Immutable @@ -77,21 +75,11 @@ internal sealed class PluginElement { @Immutable data class Text( val text: String, - val format: Format, + val format: TextFormat, ) : PluginElement() { - enum class Format { - Plain, - Markdown, - Html - } - constructor(element: SdkPluginElement.Text) : this( text = element.text, - format = when { - element.isMarkdown -> Markdown - element.isHtml -> Html - else -> Plain - } + format = element.format ) } @@ -107,8 +95,8 @@ internal sealed class PluginElement { deepLink = element.deepLink, displayInApp = element.displayInApp, action = compoundAction( - sendMessageAction(sendMessage, element.text, element.postback), - deepLinkAction(element.deepLink) + element.postback?.let { sendMessageAction(sendMessage, element.text, it) }, + element.deepLink?.let(::deepLinkAction) ) ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/BindElement.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/BindElement.kt index 5d6707cd..b6d46aa0 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/BindElement.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/BindElement.kt @@ -15,12 +15,10 @@ package com.nice.cxonechat.ui.composable.conversation.plugin -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.Message import com.nice.cxonechat.ui.composable.conversation.model.PluginElement @@ -35,7 +33,6 @@ import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Subtitl import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.TextAndButtons import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Title -import com.nice.cxonechat.ui.composable.theme.ChatTheme @Composable internal fun BindElement( @@ -60,10 +57,11 @@ internal fun BindElement( @Composable @Preview private fun PluginMessagePreview( - @PreviewParameter(PluginPreviewProvider::class) + @PreviewParameter(MessagePreviewProvider::class) message: Message ) { - PreviewMessageItemBase { - MessageItem(message = message, modifier = Modifier.padding(ChatTheme.space.large)) - } + PreviewMessageItemBase( + message = message, + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/ButtonElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/ButtonElementView.kt index 63c83e23..66aa441b 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/ButtonElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/ButtonElementView.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Button import com.nice.cxonechat.ui.composable.generic.SimpleAlertDialog @@ -63,7 +62,8 @@ internal fun ButtonElementView( @Composable @Preview private fun PreviewButton() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().button.asMessage("button")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().button.asMessage("button"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/CustomElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/CustomElementView.kt index dccd740a..3b6744b6 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/CustomElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/CustomElementView.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nice.cxonechat.ui.R.string -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Custom import com.nice.cxonechat.ui.composable.theme.ChatTheme @@ -58,7 +57,8 @@ internal fun CustomElementView(custom: Custom, modifier: Modifier = Modifier) { @Composable @Preview private fun PreviewCustom() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().custom.asMessage("custom")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().custom.asMessage("custom"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/FileElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/FileElementView.kt index c6155f3c..62fab258 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/FileElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/FileElementView.kt @@ -18,7 +18,6 @@ package com.nice.cxonechat.ui.composable.conversation.plugin import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.File import com.nice.cxonechat.ui.composable.generic.PresetAsyncImage @@ -35,7 +34,8 @@ internal fun FileElementView(file: File, modifier: Modifier = Modifier) { @Composable @Preview private fun PreviewFile() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().file.asMessage("file")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().file.asMessage("file"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/GalleryElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/GalleryElementView.kt index 6e7988e7..6ca21f9e 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/GalleryElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/GalleryElementView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Gallery @@ -44,7 +43,8 @@ internal fun GalleryElementView(gallery: Gallery, modifier: Modifier = Modifier, @Composable @Preview private fun PreviewGallery() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().gallery.asMessage("gallery")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().gallery.asMessage("gallery"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/MenuElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/MenuElementView.kt index c8e568a0..4bea8431 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/MenuElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/MenuElementView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Menu import com.nice.cxonechat.ui.composable.generic.ImageCarousel @@ -52,7 +51,8 @@ internal fun MenuElementView(menu: Menu, modifier: Modifier = Modifier) { @Composable @Preview private fun PreviewMenu() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().menu.asMessage("menu")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().menu.asMessage("menu"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/PluginPreviewProvider.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/MessagePreviewProvider.kt similarity index 82% rename from chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/PluginPreviewProvider.kt rename to chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/MessagePreviewProvider.kt index 589027f3..43736c16 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/PluginPreviewProvider.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/MessagePreviewProvider.kt @@ -19,15 +19,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.nice.cxonechat.message.Attachment import com.nice.cxonechat.message.Message.Plugin import com.nice.cxonechat.message.MessageAuthor -import com.nice.cxonechat.message.MessageDirection.ToClient +import com.nice.cxonechat.message.MessageDirection import com.nice.cxonechat.message.MessageMetadata +import com.nice.cxonechat.message.MessageStatus +import com.nice.cxonechat.message.MessageStatus.SENDING import com.nice.cxonechat.message.PluginElement import com.nice.cxonechat.message.PluginElement.QuickReplies +import com.nice.cxonechat.message.TextFormat +import com.nice.cxonechat.message.TextFormat.Html +import com.nice.cxonechat.message.TextFormat.Markdown +import com.nice.cxonechat.message.TextFormat.Plain import com.nice.cxonechat.ui.composable.conversation.model.Message import java.util.Date import java.util.UUID -internal open class PluginPreviewProvider: PreviewParameterProvider<Message> { +internal open class MessagePreviewProvider: PreviewParameterProvider<Message> { data class File( override val url: String, override val name: String, @@ -40,9 +46,14 @@ internal open class PluginPreviewProvider: PreviewParameterProvider<Message> { data class Text( override val text: String, - override val isMarkdown: Boolean = false, - override val isHtml: Boolean = false - ) : PluginElement.Text() + override val format: TextFormat = Plain, + ) : PluginElement.Text() { + @Deprecated("isMarkdown has been deprecated, please replace with format.", replaceWith = ReplaceWith("format.isMarkdown")) + override val isMarkdown: Boolean get() = format.isMarkdown + + @Deprecated("isHtml has been deprecated, please replace with format.", replaceWith = ReplaceWith("format.isHtml")) + override val isHtml: Boolean get() = format.isHtml + } data class Button( override val text: String, @@ -124,8 +135,8 @@ internal open class PluginPreviewProvider: PreviewParameterProvider<Message> { val text: List<Message> = listOf( Text("Some Text"), - Text("Some **bold** text.", isMarkdown = true), - Text("Some <b>bold</b> text.", isHtml = true) + Text("Some **bold** text.", format = Markdown), + Text("Some <b>bold</b> text.", format = Html) ).map { it.asMessage("text") } val button = Button( @@ -190,11 +201,13 @@ internal fun PluginElement.asMessage(name: String) = Message.Plugin( override val id = UUID.randomUUID() override val threadId = UUID.randomUUID() override val createdAt = Date() - override val direction = ToClient + override val direction = MessageDirection.values().random() override val metadata = object : MessageMetadata { + override val seenAt: Date? = null override val readAt = null + override val status: MessageStatus = SENDING } - override val author: MessageAuthor = object : MessageAuthor() { + override val author: MessageAuthor? = object : MessageAuthor() { override val id = "" override val firstName = "firstname" override val lastName = "lastname" diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/QuickReplyElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/QuickReplyElementView.kt index 2dff1ef8..e50e0cd3 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/QuickReplyElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/QuickReplyElementView.kt @@ -18,7 +18,6 @@ package com.nice.cxonechat.ui.composable.conversation.plugin import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.QuickReplies @@ -39,7 +38,8 @@ internal fun QuickReplyElementView(quickReplies: QuickReplies, modifier: Modifie @Composable @Preview private fun PreviewQuickReply() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().quickReply.asMessage("quick reply")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().quickReply.asMessage("quick reply"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SatisfactionSurveyElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SatisfactionSurveyElementView.kt index eb81bf6f..2e180df5 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SatisfactionSurveyElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SatisfactionSurveyElementView.kt @@ -18,7 +18,6 @@ package com.nice.cxonechat.ui.composable.conversation.plugin import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.SatisfactionSurvey @@ -41,7 +40,8 @@ internal fun SatisfactionSurveyElementView( @Composable @Preview private fun PreviewSatisfactionSurvey() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().satisfactionSurvey.asMessage("survey")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().satisfactionSurvey.asMessage("survey"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SubtitleElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SubtitleElementView.kt index b175ac8f..8c191977 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SubtitleElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/SubtitleElementView.kt @@ -19,7 +19,6 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Subtitle import com.nice.cxonechat.ui.composable.theme.ChatTheme @@ -32,7 +31,8 @@ internal fun SubtitleElementView(subtitle: Subtitle, modifier: Modifier = Modifi @Composable @Preview private fun PreviewSubtitle() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().subtitle.asMessage("subtitle")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().subtitle.asMessage("subtitle"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextAndButtonsElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextAndButtonsElementView.kt index 5fbe2ffa..2b34d30d 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextAndButtonsElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextAndButtonsElementView.kt @@ -18,7 +18,6 @@ package com.nice.cxonechat.ui.composable.conversation.plugin import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.TextAndButtons @@ -35,7 +34,8 @@ internal fun TextAndButtonsElementView(textAndButtons: TextAndButtons, modifier: @Composable @Preview private fun PreviewSubtitle() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().textAndButtons.asMessage("text and buttons")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().textAndButtons.asMessage("text and buttons"), + showSender = true, + ) } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextElementView.kt index 092bd29c..eb8df01e 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TextElementView.kt @@ -20,13 +20,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import com.nice.cxonechat.message.TextFormat.Html +import com.nice.cxonechat.message.TextFormat.Markdown +import com.nice.cxonechat.message.TextFormat.Plain import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.Message import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text -import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text.Format.Html -import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text.Format.Markdown -import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Text.Format.Plain import com.nice.cxonechat.ui.composable.generic.HtmlText import com.nice.cxonechat.ui.composable.theme.ChatTheme import dev.jeziellago.compose.markdowntext.MarkdownText @@ -43,22 +43,29 @@ internal fun TextElementView(text: Text, modifier: Modifier = Modifier) { @Composable @Preview private fun PreviewText(@PreviewParameter(TextPreviewProvider::class) message: Message) { - PreviewMessageItemBase { - MessageItem(message = message) - } + PreviewMessageItemBase( + message = message, + showSender = true, + ) } @Composable @Preview private fun PreviewAllText() { PreviewMessageItemBase { - PluginPreviewProvider().text.forEach { - MessageItem(message = it) + MessagePreviewProvider().text.forEach { + MessageItem( + message = it, + showSender = true, + onAttachmentClicked = {}, + onMoreClicked = { _, _ -> }, + onShare = {}, + ) } } } -private class TextPreviewProvider: PluginPreviewProvider() { +private class TextPreviewProvider: MessagePreviewProvider() { override val values: Sequence<Message> get() = text.asSequence() } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TitleElementView.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TitleElementView.kt index 0a7d1f0c..e6db8cb9 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TitleElementView.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/conversation/plugin/TitleElementView.kt @@ -19,7 +19,6 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.nice.cxonechat.ui.composable.conversation.MessageItem import com.nice.cxonechat.ui.composable.conversation.PreviewMessageItemBase import com.nice.cxonechat.ui.composable.conversation.model.PluginElement.Title import com.nice.cxonechat.ui.composable.theme.ChatTheme @@ -32,7 +31,8 @@ internal fun TitleElementView(title: Title, modifier: Modifier = Modifier) { @Composable @Preview private fun PreviewTitle() { - PreviewMessageItemBase { - MessageItem(message = PluginPreviewProvider().title.asMessage("title")) - } + PreviewMessageItemBase( + message = MessagePreviewProvider().title.asMessage("title"), + showSender = true, + ) } 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 new file mode 100644 index 00000000..b7f4edaf --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/CardDialog.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2021-2023. 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.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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.nice.cxonechat.ui.composable.theme.ChatTheme + +/** + * A [Dialog] with [Card] as base and predefined optional [Text] field for a title + * of the card dialog. + * + * @param title Optional text which will be displayed as title of the card. + * @param modifier [Modifier] passed to the [Card] in the [Dialog]. + * @param properties [DialogProperties] for the [Dialog]. Default is that the dialog is dismissible by clicking outside + * and by the back-press. + * @param onDismiss Action which will be passed to [Dialog] as `onDismissRequest` parameter. + * @param content Content of the [Card] in the [Dialog]. + */ +@Composable +internal fun CardDialog( + title: String?, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + ), + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + Dialog( + properties = properties, + onDismissRequest = onDismiss + ) { + Card(modifier = modifier) { + Column { + if (title != null) { + CardTitle(title, modifier = Modifier.align(Alignment.CenterHorizontally)) + } + content() + } + } + } +} + +@Preview +@Composable +private fun Preview( + @PreviewParameter(LoremIpsum::class) content: String, +) { + ChatTheme { + Surface(modifier = Modifier.fillMaxSize(0.5f)) { + CardDialog(title = "Card with title", onDismiss = {}) { + Text(text = content, maxLines = 5) + } + } + } +} + +@Preview +@Composable +private fun PreviewWithoutTitle( + @PreviewParameter(LoremIpsum::class) content: String, +) { + ChatTheme { + Surface(modifier = Modifier.fillMaxSize(0.5f)) { + CardDialog(title = null, onDismiss = {}) { + Text(text = content, maxLines = 5) + } + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExoPlayer.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExoPlayer.kt index ad7e176c..28d1cff4 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExoPlayer.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ExoPlayer.kt @@ -22,9 +22,12 @@ import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource.Factory import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer.Builder import androidx.media3.exoplayer.source.ProgressiveMediaSource +import com.nice.cxonechat.utilities.TaggingSocketFactory +import okhttp3.OkHttpClient /** * Builds instance of [ExoPlayer] and set it up using [DefaultDataSource] and [ProgressiveMediaSource]. @@ -37,14 +40,17 @@ internal fun buildProgressivePlayerForUri(context: Context, uri: Uri): ExoPlayer Builder(context) .build() .apply { - val defaultDataSourceFactory = DefaultDataSource.Factory(context) + val okHttpDataSource = OkHttpDataSource.Factory( + OkHttpClient.Builder() + .socketFactory(TaggingSocketFactory) + .build() + ) val dataSourceFactory: Factory = DefaultDataSource.Factory( context, - defaultDataSourceFactory + okHttpDataSource ) val source = ProgressiveMediaSource.Factory(dataSourceFactory) .createMediaSource(MediaItem.fromUri(uri)) - setMediaSource(source) prepare() } 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 new file mode 100644 index 00000000..5d676e9b --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/FullScreenView.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2021-2023. 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.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +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.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat.Type +import com.nice.cxonechat.ui.composable.theme.ChatTheme +import com.nice.cxonechat.ui.util.findActivity + +/** + * View which will fill out all available space and it will activity title to [title] if it is supplied, until + * the composable is disposed. + */ +@Composable +internal fun FullscreenView( + title: String?, + onExitFullScreen: () -> Unit, + content: @Composable () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxSize() + ) { + BackHandler(onBack = onExitFullScreen) + HideSystemUi() + if (title != null) { + TemporaryActivityTitle(title) + } + content() + } +} + +/** + * Wraps [content] in a [Box] with [FullscreenButton] displayed over [content] with [Alignment.BottomEnd]. + */ +@Composable +internal fun FullscreenButtonWrapper( + isFullScreen: Boolean, + onTriggerFullScreen: (Boolean) -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Box { + content() + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + AnimatedContent( + targetState = isFullScreen, + label = "button_is_fullscreen" + ) { targetState -> + if (targetState) { + FullscreenExitButton(onTriggerFullScreen) + } else { + FullscreenButton(onTriggerFullScreen) + } + } + } + } +} + +@Composable +private fun FullscreenButton(onClick: (Boolean) -> Unit) = FullscreenButton( + icon = rememberVectorPainter(image = Icons.Default.FullscreenExit), + iconOnPressed = rememberVectorPainter(image = Icons.Default.Fullscreen), + isFullScreenDefault = false, + onClick = onClick, +) + +@Composable +private fun FullscreenExitButton(onClick: (Boolean) -> Unit) = FullscreenButton( + icon = rememberVectorPainter(image = Icons.Default.Fullscreen), + iconOnPressed = rememberVectorPainter(image = Icons.Default.FullscreenExit), + isFullScreenDefault = true, + onClick = onClick +) + +/** + * Turn-on immersive mode until the composable is disposed. + */ +@Composable +private fun HideSystemUi() { + val context = LocalContext.current + val view = LocalView.current + DisposableEffect(context, view) { + val activity = context.findActivity() ?: return@DisposableEffect onDispose { } + val windowInsetsController = + WindowCompat.getInsetsController(activity.window, view) + val types = Type.systemBars() + windowInsetsController.hide(types) + onDispose { + windowInsetsController.show(types) + } + } +} + +@Composable +private fun FullscreenButton( + icon: VectorPainter, + iconOnPressed: VectorPainter, + modifier: Modifier = Modifier, + isFullScreenDefault: Boolean, + onClick: (Boolean) -> Unit, +) { + val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + IconButton( + modifier = modifier + .background( + shape = CircleShape, + color = ChatTheme.colors.surface.copy(alpha = 0.6f) + ), + onClick = { onClick(!isFullScreenDefault) }, + interactionSource = interactionSource, + ) { + val isPressed by interactionSource.collectIsPressedAsState() + AnimatedContent(targetState = isPressed, label = "isPressed") { pressed -> + if (pressed) { + Icon(painter = iconOnPressed, contentDescription = null) + } else { + Icon(painter = icon, contentDescription = null) + } + } + } +} + +@Composable +@Preview +private fun PreviewButton() { + ChatTheme { + Surface { + var isFullscreen by remember { mutableStateOf(false) } + FullscreenButtonWrapper( + isFullscreen, + onTriggerFullScreen = { isFullscreen = it } + ) { + Text("FullScreen: $isFullscreen") + } + } + } +} 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 new file mode 100644 index 00000000..41619a99 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ImageViewerDialogCard.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2021-2023. 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 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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.nice.cxonechat.ui.composable.theme.ChatTheme + +/** + * A [CardDialog] which will display a [ZoomableImage] with option to switch to the [FullscreenView] via button. + * The dialog is dismissed if the user click outside the view or taps the back button. + * The fullscreen view is dismissed back to the dialog. + * + * @param image The model for [ZoomableImage]. + * @param title An optional title for the [CardDialog]. + * @param onDismiss An action triggered when the dialog is dismissed. + */ +@Composable +internal fun ImageViewerDialogCard( + image: Any?, + title: String?, + onDismiss: () -> Unit, +) { + var isFullScreen by rememberSaveable { mutableStateOf(false) } + val content: @Composable BoxScope.() -> Unit = { + ZoomableImage( + image = image, + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + ) + } + AnimatedContent( + targetState = isFullScreen, + label = "fullScreen", + ) { fullScreen -> + if (fullScreen) { + FullscreenView( + title = title, + onExitFullScreen = { isFullScreen = false } + ) { + FullscreenButtonWrapper( + isFullScreen = true, + onTriggerFullScreen = { isFullScreen = it }, + content = content + ) + } + } else { + CardDialog( + title = title, + onDismiss = onDismiss + ) { + FullscreenButtonWrapper( + isFullScreen = false, + onTriggerFullScreen = { isFullScreen = it }, + content = content + ) + } + } + } +} + +@Composable +@Preview +private fun PreviewDialog() { + @Suppress("MaxLineLength") + val image = """https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/St_Michael%27s_Mount_II5302_x_2982.jpg/1024px-St_Michael%27s_Mount_II5302_x_2982.jpg""" + + ChatTheme { + val context = LocalContext.current + ImageViewerDialogCard( + image = image, + title = "St Michaels Mount, Marazion in Cornwall UK", + onDismiss = { + Toast.makeText(context, "Dismissed", Toast.LENGTH_SHORT).show() + } + ) + } +} + +@Composable +@Preview +private fun PreviewCardTitle() { + ChatTheme { + Surface { + CardTitle(title = "St Michaels Mount, Marazion in Cornwall UK") + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/TemporaryActivityTitle.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/TemporaryActivityTitle.kt new file mode 100644 index 00000000..564fe1ad --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/TemporaryActivityTitle.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021-2023. 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 android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import com.nice.cxonechat.ui.util.findActivity + +/** + * Set temporary title to parent [Activity] via [DisposableEffect]. + * When the [Composable] is disposed, the original title will be set back. + * + * Avoid using multiple parallel [TemporaryActivityTitle] since, + * there is no guarantee that disposing will happen in reverse order + * as the original application of effects. + * + * @param title A temporary title which will be set. + */ +@Composable +internal fun TemporaryActivityTitle(title: String) { + val context = LocalContext.current + DisposableEffect(key1 = title) { + val activity: Activity = context.findActivity() ?: return@DisposableEffect onDispose {} + val originalTitle = activity.title + activity.title = title + onDispose { activity.title = originalTitle } + } +} 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 663269d0..8aea669b 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 @@ -21,10 +21,15 @@ import androidx.compose.ui.Modifier import com.nice.cxonechat.ui.composable.theme.ChatTheme @Composable -internal fun DialogTitle(text: String, modifier: Modifier = Modifier) { - Text( - text = text, - style = ChatTheme.chatTypography.dialogTitle, - modifier = modifier, - ) -} +internal fun DialogTitle(text: String, modifier: Modifier = Modifier) = Text( + text = text, + style = ChatTheme.chatTypography.dialogTitle, + modifier = modifier, +) + +@Composable +internal fun CardTitle(title: String, modifier: Modifier = Modifier) = Text( + text = title, + style = ChatTheme.chatTypography.chatCardTitle, + modifier = modifier, +) 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 8d493c81..89251b3a 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 @@ -29,7 +29,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -40,7 +39,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nice.cxonechat.ui.R.string +import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.LocalSpace +import com.nice.cxonechat.ui.util.toggle internal interface TreeFieldItem<ValueType> { val label: String @@ -63,21 +64,18 @@ internal fun <ValueType> TreeField( modifier: Modifier = Modifier, label: String = "", items: List<TreeFieldItem<ValueType>>, - expanded: Array<TreeFieldItem<ValueType>> = arrayOf(), - canSelect: (TreeFieldItem<ValueType>) -> Boolean, + isExpanded: (TreeFieldItem<ValueType>) -> Boolean, isSelected: (TreeFieldItem<ValueType>) -> Boolean, - toggleSelected: (TreeFieldItem<ValueType>) -> Unit, + onNodeClicked: (TreeFieldItem<ValueType>) -> Unit, + onExpandClicked: (TreeFieldItem<ValueType>) -> Unit, ) { - val expandedItems = remember { mutableStateListOf<TreeFieldItem<ValueType>>() } - expandedItems.addAll(expanded) - Row( verticalAlignment = Alignment.Top, modifier = modifier .fillMaxWidth() .defaultMinSize(minHeight = LocalSpace.current.clickableSize) ) { - Column { + Column(modifier = Modifier.fillMaxWidth()) { if(label.isNotBlank()) { Text(text = label, modifier = Modifier.padding(LocalSpace.current.treeFieldIndent)) Spacer(Modifier.size(16.dp)) @@ -85,21 +83,12 @@ internal fun <ValueType> TreeField( items.forEach { item -> TreeNode( node = item, - modifier = modifier, + modifier = modifier.fillMaxWidth(), indent = 8.dp, - isExpanded = { node -> - expandedItems.contains(node) - }, - toggleExpanded = { node -> - if(expandedItems.contains(node)) { - expandedItems.remove(node) - } else { - expandedItems.add(node) - } - }, - canSelect = canSelect, + isExpanded = isExpanded, isSelected = isSelected, - toggleSelected = toggleSelected + onNodeClicked = onNodeClicked, + onExpandClicked = onExpandClicked, ) } } @@ -113,39 +102,35 @@ private fun <ValueType> TreeNode( modifier: Modifier, indent: Dp, isExpanded: (TreeFieldItem<ValueType>) -> Boolean, - toggleExpanded: (TreeFieldItem<ValueType>) -> Unit, - canSelect: (TreeFieldItem<ValueType>) -> Boolean, isSelected: (TreeFieldItem<ValueType>) -> Boolean, - toggleSelected: (TreeFieldItem<ValueType>) -> Unit, + onNodeClicked: (TreeFieldItem<ValueType>) -> Unit, + onExpandClicked: (TreeFieldItem<ValueType>) -> Unit, ) { + val expanded = isExpanded(node) + Column { Row( - modifier = modifier, + modifier = modifier.clickable { + onNodeClicked(node) + }, verticalAlignment = Alignment.CenterVertically, ) { when { node.isLeaf -> Spacer(Modifier.size(LocalSpace.current.clickableSize)) else -> ExpandableIcon(expanded = isExpanded(node)) { - toggleExpanded(node) + onExpandClicked(node) } } Text( node.label, - Modifier.clickable { - if (canSelect(node)) { - toggleSelected(node) - } else { - toggleExpanded(node) - } - } ) if (isSelected(node)) { - SelectedIcon(node.label) + SelectedIcon(node.label, modifier = Modifier.padding(start = ChatTheme.space.small)) } } val children = node.children - if (children != null && isExpanded(node)) { + if (children != null && expanded) { Column(Modifier.padding(start = LocalSpace.current.treeFieldIndent)) { children.forEach { TreeNode( @@ -153,10 +138,9 @@ private fun <ValueType> TreeNode( modifier = modifier, indent = indent + LocalSpace.current.treeFieldIndent, isExpanded = isExpanded, - toggleExpanded = toggleExpanded, - canSelect = canSelect, isSelected = isSelected, - toggleSelected = toggleSelected, + onNodeClicked = onNodeClicked, + onExpandClicked = onExpandClicked, ) } } @@ -165,7 +149,7 @@ private fun <ValueType> TreeNode( } @Composable -private fun SelectedIcon(label: String) { +private fun SelectedIcon(label: String, modifier: Modifier = Modifier) { Icon( imageVector = Icons.Default.Check, contentDescription = stringResource( @@ -173,7 +157,8 @@ private fun SelectedIcon(label: String) { formatArgs = arrayOf( label, ) - ) + ), + modifier = modifier, ) } @@ -196,17 +181,68 @@ private fun TreeFieldPreview() { ), SimpleTreeFieldItem("Node 1", "1") ) - var value by remember { mutableStateOf("0-1") } + var selected: TreeFieldItem<String>? by remember { + mutableStateOf(nodes.findRecursive { it.value == "0-0-0" }) + } + var expanded: Set<TreeFieldItem<String>> by remember { mutableStateOf(setOf()) } Column { - Text(value) // Serves as a double-check that the correct value is set. + Text(selected?.label ?: "") + TreeField( label = "Label", items = nodes, - expanded = arrayOf(nodes.first()), - canSelect = { it.isLeaf }, - isSelected = { it.value == value }, - toggleSelected = { value = it.value }, + isExpanded = expanded::contains, + isSelected = { selected == it }, + onNodeClicked = { node -> + if (node.isLeaf) { + selected = if (selected == node) null else node + } else { + expanded = expanded.toggle(node) + } + }, + onExpandClicked = { node -> + expanded = expanded.toggle(node) + } ) } } + +/** + * Recursive searches [TreeFieldItem] and builds a path to the node matching [test]. + * + * @param Type type of value items in the included [TreeFieldItem]. + * @param test Test function to match the node being sought. + * @return list of items to traverse to reach the matching node or null if no + * match was found. + */ +@Suppress("ReturnCount") +internal fun <Type> Iterable<TreeFieldItem<Type>>.pathToNode( + test: (TreeFieldItem<Type>) -> Boolean +): List<TreeFieldItem<Type>>? { + for(child in this) { + if (test(child)) { + return listOf(child) + } + + child.children?.pathToNode(test)?.let { + return listOf(child) + it + } + } + return null +} + +/** + * Recursive searches [TreeFieldItem] and returns the matching item. + * + * @param Type type of value items in the included [TreeFieldItem]. + * @param test Test function to match the node being sought. + * @return matching item or null if no match is found. + */ +internal fun <Type> Iterable<TreeFieldItem<Type>>.findRecursive( + test: (TreeFieldItem<Type>) -> Boolean +): TreeFieldItem<Type>? { + return fold(null as TreeFieldItem<Type>?) { found, node -> + found ?: if (test(node)) node else node.children?.findRecursive(test) + } +} 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 23432f29..d3ec4c03 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,32 +23,55 @@ 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.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.R.string import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.C +import androidx.media3.common.C.VideoScalingMode import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import androidx.media3.ui.PlayerView.FullscreenButtonClickListener import com.nice.cxonechat.ui.composable.theme.LocalSpace /** * ExoPlayer wrapped as Composable, set for video playing. + * + * @param uri An [Uri] of the video to be played. + * @param modifier A [Modifier] which should be used by the player view. + * @param videoScalingMode A [VideoScalingMode] to be used by the player, + * the default is [C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING]. + * @param onFullScreenClickListener An optional listener which will */ @Composable @OptIn(UnstableApi::class) -internal fun VideoPlayer(uri: Uri, modifier: Modifier = Modifier) { - if (LocalInspectionMode.current) { +internal fun VideoPlayer( + uri: Uri?, + modifier: Modifier = Modifier, + @VideoScalingMode videoScalingMode: Int = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING, + onFullScreenClickListener: FullscreenButtonClickListener? = null, +) { + if (uri == null) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = stringResource(id = string.default_error_message) + ) + } else if (LocalInspectionMode.current) { // ExoPlayer is not working in Preview mode LocalInspectionPlaceholder() } else { @@ -59,11 +82,11 @@ internal fun VideoPlayer(uri: Uri, modifier: Modifier = Modifier) { } exoPlayer.playWhenReady = true - exoPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + exoPlayer.videoScalingMode = videoScalingMode exoPlayer.repeatMode = Player.REPEAT_MODE_OFF AndroidView( - factory = { viewContext -> playerFactory(viewContext, exoPlayer) }, + factory = { viewContext -> playerFactory(viewContext, exoPlayer, onFullScreenClickListener) }, modifier = modifier, onRelease = { playerView -> playerView.player?.release() }, update = { playerView -> playerView.updatePlayer(exoPlayer) }, @@ -80,12 +103,17 @@ private fun PlayerView.updatePlayer(exoPlayer: ExoPlayer) { } @OptIn(UnstableApi::class) -private fun playerFactory(viewContext: Context, exoPlayer: ExoPlayer) = +private fun playerFactory( + viewContext: Context, + exoPlayer: ExoPlayer, + onFullScreenClickListener: FullscreenButtonClickListener?, +) = PlayerView(viewContext).apply { controllerAutoShow = true resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM player = exoPlayer layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + setFullscreenButtonClickListener(onFullScreenClickListener) } @Preview 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 new file mode 100644 index 00000000..932b8f8a --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/VideoViewerDialogCard.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2021-2023. 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 android.net.Uri +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource + +/** + * A [CardDialog] for video which can be replaced with [FullscreenView] if the user, + * toggles the fullscreen button. + * The dialog is dismissed if the user click outside the view or taps the back button. + * The fullscreen view is dismissed back to the dialog. + * + * @param uri The [Uri] of the video to be played. + * @param title An optional title for the [CardDialog]. + * @param onDismiss An action which will be triggered if the view is dismissed. + */ +@Composable +internal fun VideoViewerDialogCard( + uri: String, + title: String?, + onDismiss: () -> Unit, +) { + var isFullScreen by rememberSaveable { mutableStateOf(false) } + val parsedUri by remember { + derivedStateOf { + runCatching { Uri.parse(uri) }.getOrNull() + } + } + val content: @Composable () -> Unit = { + if (parsedUri != null) { + VideoPlayer(uri = parsedUri, onFullScreenClickListener = { isFullScreen = it }) + } else { + Text(text = stringResource(androidx.media3.exoplayer.R.string.exo_download_failed)) + } + } + if (isFullScreen) { + FullscreenView( + title = title, + onExitFullScreen = { isFullScreen = false } + ) { + content() + } + } else { + CardDialog( + title = title, + onDismiss = onDismiss + ) { + content() + } + } +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ZoomableImage.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ZoomableImage.kt new file mode 100644 index 00000000..cc4e83bf --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/generic/ZoomableImage.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021-2023. 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.material.icons.Icons +import androidx.compose.material.icons.filled.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import coil.compose.AsyncImage +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable + +/** + * A [AsyncImage] with applied [zoomable] modifier. + * + * @param image A model for [AsyncImage]. + * @param modifier A [Modifier] for [AsyncImage]. + * @param contentDescription A contentDescription for [AsyncImage]. + */ +@Composable +internal fun ZoomableImage( + image: Any?, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + AsyncImage( + model = image, + contentDescription = contentDescription, + placeholder = rememberVectorPainter(image = Icons.Default.Image), + modifier = modifier.zoomable(rememberZoomState()), + ) +} 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 new file mode 100644 index 00000000..f51a9351 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/BusySpinner.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2021-2023. 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.theme + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.composable.theme.ChatTheme.space + +/** + * Display a busy spinner and a status message. + */ +@Composable +fun BusySpinner(message: String, onCancel: (() -> Unit)? = null) { + Dialog( + onDismissRequest = { }, + ) { + Card(backgroundColor = MaterialTheme.colors.background.copy(alpha = 0.75f)) { + Column( + modifier = Modifier.padding(space.defaultPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(space.medium), + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colors.primary, + ) + Text(message) + onCancel?.let { + ChatTheme.OutlinedButton( + text = stringResource(id = R.string.cancel), + onClick = onCancel + ) + } + } + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun BusySpinnerPreview() { + ChatTheme { + BusySpinner(message = "Loading...", onCancel = { }) + } +} 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 509d9855..2aa43cb4 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 @@ -31,6 +31,7 @@ internal data class ChatShapes( val chatVideoPlayer: Shape = DefaultChatShapes.chatVideoPlayerClip, val chatAudioPlayer: Shape = DefaultChatShapes.chatAudioPlayerClip, val chip: Shape = DefaultChatShapes.chip, + val selectionFrame: Shape = DefaultChatShapes.selectionFrame, ) internal object DefaultChatShapes { @@ -58,6 +59,8 @@ internal object DefaultChatShapes { val chatAudioPlayerClip = RoundedCornerShape(8.dp) val chip = RoundedCornerShape(8.dp) + + val selectionFrame = RoundedCornerShape(8.dp) } internal val LocalChatShapes = staticCompositionLocalOf { 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 720b6d03..526943ec 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 @@ -25,6 +25,9 @@ import androidx.compose.ui.text.style.TextAlign 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, diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ColorDefinitions.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ColorDefinitions.kt deleted file mode 100644 index 7e204dd5..00000000 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/ColorDefinitions.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2021-2023. 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.theme - -import androidx.compose.ui.graphics.Color - -internal object ColorDefinitions { - interface DefaultColors { - val primary: Color - val onPrimary: Color - val background: Color - val onBackground: Color - val accent: Color - val agentBackground: Color - val agentText: Color - val customerBackground: Color - val customerText: Color - } - - private val purple_500 = Color(0xFF6200EE) - private val teal_200 = Color(0xFF03DAC5) - private val white = Color(0xFFFFFFFF) - private val black = Color(0xFF000000) - private val gray_light = Color(0xFFe8e8e8) - private val dark_background = Color(0xFF424242) - private val light_background = Color(0xFFFFFFFF) - private val cornflower_blue_two = Color(0xFF4F62D7) - private val dark_gray_two = Color(0xFF191A1B) - - object Light: DefaultColors { - override val primary = purple_500 - override val onPrimary = white - override val background = white - override val onBackground = black - override val accent = teal_200 - override val agentBackground = light_background - override val agentText = dark_gray_two - override val customerBackground = cornflower_blue_two - override val customerText = white - } - - object Dark: DefaultColors { - override val primary = purple_500 - override val onPrimary = white - override val background = dark_gray_two - override val onBackground = white - override val accent = teal_200 - override val agentBackground = dark_background - override val agentText = gray_light - override val customerBackground = cornflower_blue_two - override val customerText = white - } -} 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 1ac44cd8..e4fb8233 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 @@ -19,7 +19,6 @@ 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.material.TextField 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/theme/Scaffold.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Scaffold.kt index 55349c45..ff413f38 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 @@ -28,6 +28,7 @@ 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 @@ -50,12 +51,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode 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 coil.ImageLoader import coil.compose.AsyncImage +import kotlinx.coroutines.Dispatchers @Composable internal fun ChatTheme.Scaffold( @@ -92,10 +96,10 @@ internal fun ChatTheme.TopBar( modifier: Modifier = Modifier, logo: Any? = images.logo, navigationIcon: @Composable (() -> Unit)? = null, - actions: @Composable RowScope.() -> Unit = {}, backgroundColor: Color = colors.primary, contentColor: Color = colors.onPrimary, elevation: Dp = AppBarDefaults.TopAppBarElevation, + actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( title = { @@ -110,8 +114,28 @@ internal fun ChatTheme.TopBar( ) } +@Composable +internal fun ChatTheme.BottomBar( + modifier: Modifier = Modifier, + backgroundColor: Color = colors.primary, + contentColor: Color = colors.onPrimary, + elevation: Dp = AppBarDefaults.BottomAppBarElevation, + content: @Composable RowScope.() -> Unit +) { + BottomAppBar( + modifier = modifier, + backgroundColor = backgroundColor, + contentColor = contentColor, + elevation = elevation, + content = content + ) +} + @Composable private fun ChatTheme.TopBarTitle(logo: Any?, title: String) { + val size = space.titleBarLogoSize + val padding = Dp(space.titleBarLogoPadding / LocalContext.current.resources.displayMetrics.density) + Row( verticalAlignment = Alignment.CenterVertically, ) { @@ -119,10 +143,10 @@ private fun ChatTheme.TopBarTitle(logo: Any?, title: String) { AsyncImage( model = logo, modifier = Modifier - .fillMaxHeight() - .size(space.clickableSize) - .padding(space.small), + .size(size) + .padding(padding), contentDescription = null, + imageLoader = ImageLoader.Builder(LocalContext.current).interceptorDispatcher(Dispatchers.IO).build(), placeholder = if (LocalInspectionMode.current) { // Default mipmap has issues in preview. painterResource(id = drawable.ic_dialog_map) } else { 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 new file mode 100644 index 00000000..05e70f20 --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/SelectionFrame.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021-2023. 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.theme + +import androidx.compose.foundation.BorderStroke +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal fun ChatTheme.SelectionFrame( + modifier: Modifier = Modifier, + selected: Boolean = false, + content: @Composable () -> Unit +) { + val strokeWidth = if (selected) space.selectedFrameWidth else space.unselectedFrameWidth + + Surface( + modifier = modifier, + shape = chatShapes.selectionFrame, + elevation = strokeWidth, + content = content, + border = BorderStroke( + strokeWidth, + if (selected) MaterialTheme.colors.primary else LocalContentColor.current + ) + ) +} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Space.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Space.kt index 079419a9..7fae71c7 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Space.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/composable/theme/Space.kt @@ -40,6 +40,25 @@ internal data class Space( val chipSpace: Dp = medium, val chipPadding: PaddingValues = PaddingValues(vertical = medium, horizontal = large), val menuElementHeight: Dp = 100.dp, + val titleBarLogoSize: Dp = 24.dp, // per android recommendations + val titleBarLogoPadding: Float = 2f, // per android recommendations this is in Pixels, not Dp. + + /** how many icons to display in the message window. */ + val smallAttachmentCount: Int = 4, + /** size of icons in the message window. */ + val smallAttachmentSize: Dp = 44.dp, + /** padding around icons in the message window. */ + val smallAttachmentPadding: PaddingValues = PaddingValues(small), + + /** size of icons in the attachment sharing dialog. */ + val largeAttachmentSize: Dp = 60.dp, + /** padding around icons in the attachment sharing dialog. */ + val largeAttachmentPadding: PaddingValues = PaddingValues(small), + + /** selected frame stroke width. */ + val selectedFrameWidth: Dp = 3.dp, + /** unselected frame stroke width. */ + val unselectedFrameWidth: Dp = 1.dp, ) internal val LocalSpace = staticCompositionLocalOf { 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 230d15fb..b48a8598 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Card import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,11 +52,13 @@ import com.nice.cxonechat.ui.composable.generic.SimpleDropdownItem import com.nice.cxonechat.ui.composable.generic.SimpleTreeFieldItem import com.nice.cxonechat.ui.composable.generic.TreeField import com.nice.cxonechat.ui.composable.generic.TreeFieldItem +import com.nice.cxonechat.ui.composable.generic.pathToNode import com.nice.cxonechat.ui.composable.theme.ChatTheme import com.nice.cxonechat.ui.composable.theme.ChatTheme.space import com.nice.cxonechat.ui.composable.theme.FieldLabelDecoration import com.nice.cxonechat.ui.composable.theme.TextField import com.nice.cxonechat.ui.util.isEmpty +import com.nice.cxonechat.ui.util.toggle @Composable internal fun CVFieldList(fields: CustomValueItemList) { @@ -152,21 +155,25 @@ private fun <ValueType> Sequence<HierarchyNode<ValueType>>.toTreeFieldItemList() SimpleTreeFieldItem( it.label, it, - it.children.firstOrNull()?.children?.toTreeFieldItemList(), + it.children.toTreeFieldItemList().ifEmpty { null }, ) }.toList() } +private typealias CVHFItem = TreeFieldItem<HierarchyNode<String>> + @Composable private fun CVHierarchyField(item: CustomValueItem.Hierarchy) { val details = item.definition val requiredError = stringResource(id = string.error_required_field) val valueError = stringResource(id = string.error_value_validation) - var node by remember { item.response } + val nodes by remember { mutableStateOf(details.values.toTreeFieldItemList()) } + var selected by remember { item.response } + var expanded: Set<CVHFItem> by remember { mutableStateOf(setOf()) } var error: String? by remember { mutableStateOf(null) } val label = when { error != null -> error - node != null -> details.label + selected != null -> details.label else -> null } @@ -178,19 +185,30 @@ private fun CVHierarchyField(item: CustomValueItem.Hierarchy) { } } + fun expandClicked(node: CVHFItem) { + expanded = expanded.toggle(node) + } + + fun selectClicked(node: CVHFItem) { + if (node.isLeaf) { + selected = if (selected == node.value) null else node.value + validate(selected) + } else { + expandClicked(node) + } + } + + LaunchedEffect(Unit) { + expanded = nodes.pathToNode { it.value == selected }?.toSet() ?: setOf() + } + ChatTheme.FieldLabelDecoration(label = label, isError = error != null) { TreeField( - items = details.values.toTreeFieldItemList(), - canSelect = { it.isLeaf }, - isSelected = { it.value == node }, - toggleSelected = { selected -> - node = if (node == selected.value) { - null - } else { - selected.value - } - validate(node) - } + items = nodes, + isSelected = { it.value == selected }, + isExpanded = expanded::contains, + onNodeClicked = ::selectClicked, + onExpandClicked = ::expandClicked ) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ContentDataSourceList.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ContentDataSourceList.kt index a60eab0a..4fd733b4 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ContentDataSourceList.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ContentDataSourceList.kt @@ -18,23 +18,21 @@ package com.nice.cxonechat.ui.data import android.content.Context import android.net.Uri import com.nice.cxonechat.message.ContentDescriptor -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton +import org.koin.core.annotation.Single /** * List of available [ContentDataSource] which can be used * to process an content URI for attachment. * - * @property context for content resolving + * @param context for content resolving * @param imageContentDataSource [ContentDataSource] for images * @param documentContentDataSource [ContentDataSource] for videos and pdf * documents and other attachments that are treated as raw data * @param audioContentDataSource [ContentDataSource] for audio */ -@Singleton -internal class ContentDataSourceList @Inject constructor( - @ApplicationContext private val context: Context, +@Single +internal class ContentDataSourceList( + private val context: Context, imageContentDataSource: ImageContentDataSource, documentContentDataSource: DocumentContentDataSource, audioContentDataSource: MediaStoreAudioContentDataSource, diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/DocumentContentDataSource.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/DocumentContentDataSource.kt index 851b237a..8fd4e2d5 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/DocumentContentDataSource.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/DocumentContentDataSource.kt @@ -19,21 +19,21 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import com.nice.cxonechat.message.ContentDescriptor -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.runInterruptible +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton /** * [ContentDataSource] for videos, pdf documents and other attachments that are * treated as raw data. * - * @property context Context for content resolver + * @param context Context for content resolver */ -@Singleton -internal class DocumentContentDataSource @Inject constructor( - @ApplicationContext private val context: Context, +@Single +@Module() +internal class DocumentContentDataSource( + private val context: Context, ) : ContentDataSource { override val acceptRegex = Regex("""(video/.*|application/pdf)""") diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ImageContentDataSource.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ImageContentDataSource.kt index 6720a68f..e5842c68 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ImageContentDataSource.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/ImageContentDataSource.kt @@ -22,14 +22,12 @@ import android.net.Uri import android.os.Build import android.provider.MediaStore import com.nice.cxonechat.message.ContentDescriptor -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.runInterruptible +import org.koin.core.annotation.Single import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.io.IOException -import java.util.* -import javax.inject.Inject -import javax.inject.Singleton +import java.util.UUID /** * [ContentDataSource] that handles Image uri's for uploading to the host. @@ -38,11 +36,11 @@ import javax.inject.Singleton * before a [ContentDescriptor] is created. This means that a fairly large * chunk of memory will be consumed while the ContentDescriptor is held. * - * @property context Context to be used for Uri resolution + * @param context Context to be used for Uri resolution */ -@Singleton -internal class ImageContentDataSource @Inject constructor( - @ApplicationContext private val context: Context, +@Single +internal class ImageContentDataSource( + private val context: Context, ) : ContentDataSource { override val acceptRegex = Regex("image/.*") @@ -62,7 +60,7 @@ internal class ImageContentDataSource @Inject constructor( // can convert it to JPG from whatever it happens to currently be, which // is probably either PNG or WEBP content = getContent(attachmentUri) ?: return@runInterruptible null, - mimeType = "image/jpg", + mimeType = "image/jpeg", fileName = "${UUID.randomUUID()}.jpg", friendlyName = attachmentUri.lastPathSegment ) diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/MediaStoreAudioContentDataSource.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/MediaStoreAudioContentDataSource.kt index 0362bce1..5022f5d4 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/MediaStoreAudioContentDataSource.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/MediaStoreAudioContentDataSource.kt @@ -19,20 +19,18 @@ import android.content.Context import android.net.Uri import android.provider.MediaStore import com.nice.cxonechat.message.ContentDescriptor -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton +import org.koin.core.annotation.Single /** * [ContentDataSource] for media audio files based on android data storage. * - * @property context Application context used for queries to ContentResolver. + * @param context Application context used for queries to ContentResolver. */ -@Singleton -internal class MediaStoreAudioContentDataSource @Inject constructor( - @ApplicationContext private val context: Context, +@Single +internal class MediaStoreAudioContentDataSource( + private val context: Context, ) : ContentDataSource { override val acceptRegex: Regex = "audio/.*".toRegex() @@ -47,8 +45,7 @@ internal class MediaStoreAudioContentDataSource @Inject constructor( content = attachmentUri, context = context, mimeType = cursor.getString(mimeTypeIndex), - fileName = null, - friendlyName = cursor.getString(nameIndex) + fileName = cursor.getString(nameIndex), ) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/PinpointPushMessageParser.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/PinpointPushMessageParser.kt index b74a98be..17abda5a 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/PinpointPushMessageParser.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/data/PinpointPushMessageParser.kt @@ -24,14 +24,12 @@ import android.os.Build.VERSION_CODES import androidx.annotation.DrawableRes import com.nice.cxonechat.ui.domain.PushMessage import com.nice.cxonechat.ui.domain.PushMessageParser -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject /** * Pinpoint specific implementation of [PushMessageParser]. */ -internal class PinpointPushMessageParser @Inject constructor( - @ApplicationContext private val context: Context, +internal class PinpointPushMessageParser( + private val context: Context, ) : PushMessageParser { override fun parse(data: Map<String, String>): PushMessage { diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/AttachmentSharingRepository.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/AttachmentSharingRepository.kt index 116ba9e2..f5603b22 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/AttachmentSharingRepository.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/AttachmentSharingRepository.kt @@ -13,107 +13,177 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ +@file:Suppress("ForbiddenImport") + package com.nice.cxonechat.ui.domain import android.content.Context import android.content.Intent import android.net.Uri -import com.nice.cxonechat.ui.composable.conversation.model.Message.Attachment +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.error +import com.nice.cxonechat.log.scope +import com.nice.cxonechat.message.Attachment +import com.nice.cxonechat.ui.R.string import com.nice.cxonechat.ui.storage.TemporaryFileProvider import com.nice.cxonechat.ui.storage.TemporaryFileStorage import com.nice.cxonechat.ui.util.await import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request.Builder import okio.buffer -import okio.sink import okio.source +import org.koin.core.annotation.Single import java.io.File import java.io.InputStream -import javax.inject.Inject -import javax.inject.Singleton /** * Class responsible for caching of attachments before they can be shared * and creation of [Intent] which will be used for sharing. + * + * @param storage [TemporaryFileStorage] to cache files before sharing. + * @param httpClient [OkHttpClient] used to fetch remote attachments for sharing. + * @param logger [Logger] for logging warnings and errors. */ -@Singleton -internal class AttachmentSharingRepository @Inject constructor( +@Single +internal class AttachmentSharingRepository( private val storage: TemporaryFileStorage, -) { - - private val client by lazy { OkHttpClient() } + private val httpClient: OkHttpClient, + private val logger: Logger, +) : LoggerScope by LoggerScope<AttachmentSharingRepository>(logger) { + /** return the type of a string representing mimetype. */ + private val String.type: String? + get() = split("/").firstOrNull() /** * Caches file from the original url to storage and creates Intent with action [Intent.ACTION_SEND] set to provide * data via stream from [TemporaryFileProvider]. * - * @param message AttachmentMessage whose content will be cached to file and that file shared. + * @param attachments Attachments that will be cached to local storage and shared. * @param context Context which will be used for caching. * @return Prepared intent or null if caching has failed. */ suspend fun createSharingIntent( - message: Attachment, + attachments: Collection<Attachment>, context: Context, - ): Intent? = runCatching { - val filename = message.text - val file = cacheUrlContents(message.originalUrl, filename, context) ?: return null - val uri = TemporaryFileProvider.getUriForFile(file, filename, context) - return Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TITLE, "Share attachment $filename") - putExtra(Intent.EXTRA_TEXT, filename) - putExtra(Intent.EXTRA_STREAM, uri) - setDataAndType(uri, message.mimeType) - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + ): Intent? { + val mapped = attachments.mapNotNull { + context.prepareAttachment(it) } - }.getOrNull() - private suspend fun cacheUrlContents( + return when (mapped.count()) { + 0 -> null + 1 -> Intent().apply { + val attachment = mapped.first() + val url = Uri.parse(attachment.url) + + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TITLE, context.getString(string.share_attachment, attachment.friendlyName)) + putExtra(Intent.EXTRA_TEXT, attachment.friendlyName) + putExtra(Intent.EXTRA_STREAM, url) + setDataAndType(url, attachment.mimeType) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + else -> Intent().apply { + action = Intent.ACTION_SEND_MULTIPLE + putExtra( + Intent.EXTRA_TITLE, + context.getString( + string.share_attachment_others, + mapped.first().friendlyName, + mapped.count() - 1 + ) + ) + putExtra(Intent.EXTRA_TEXT, ArrayList(mapped.map(Attachment::friendlyName))) + putExtra(Intent.EXTRA_STREAM, ArrayList(mapped.map { Uri.parse(it.url) })) + type = mapped.map(Attachment::mimeType).commonMimeType() + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + } + } + + private fun Collection<String?>.commonMimeType() = fold(null as String?) { acc, value -> + when { + acc == null -> value ?: "*/*" + acc == value -> acc + acc.type == value?.type -> "${acc.type}/*" + else -> "*/*" + } + } ?: "*/*" + + private suspend fun Context.prepareAttachment(attachment: Attachment) = scope("Context.prepareAttachment") { + runCatching { + val file = cacheUrlContents(url = attachment.url, hint = attachment.friendlyName) ?: return null + val uri = TemporaryFileProvider.getUriForFile(file, file.name, this@prepareAttachment) + + object : Attachment { + override val url = uri.toString() + override val friendlyName = attachment.friendlyName + override val mimeType = attachment.mimeType + } + } + .onFailure { + error("Error preparing: ${attachment.friendlyName}", it) + } + .getOrNull() + } + + private suspend fun Context.cacheUrlContents( url: String, - name: String, - context: Context, - ): File? = withContext(Dispatchers.IO) { + hint: String, + ): File? = scope("Context.cacheUrlContents") { val uri = Uri.parse(url) val stream: InputStream = when (uri.scheme) { // Attachment can be available via content if the message was just sent, before it is updated from backend. - "content" -> runCatching { context.contentResolver.openInputStream(uri) }.getOrNull() - "http", "https" -> { - val request = Builder().url(url).build() - runCatching { client.newCall(request).await() }.getOrNull()?.byteStream() - } + "content" -> + runCatching { + contentResolver.openInputStream(uri) + } + .onFailure { + error("Error opening content: $url", it) + } + .getOrNull() + + "http", "https" -> + runCatching { + httpClient.newCall( + Builder().url(url).build() + ).await() + } + .onFailure { + error("Error opening http: $url", it) + } + .getOrNull() + ?.byteStream() + else -> null - } ?: return@withContext null - storeToCache(stream, name) + } ?: return null + + return storeToCache(stream, hint) } /** * Simple function which writes given [InputStream] to file which will be accessible from [TemporaryFileProvider]. - * If there is already a file for given [fileName], then it will be overwritten. + * A new filename will be randomly created. [hint] will be used for error messages. */ - private suspend fun storeToCache(inputStream: InputStream, fileName: String): File? = withContext(Dispatchers.IO) { - runCatching { - storage.createFile(fileName)?.also { file -> - val sink = file.outputStream().sink().buffer() - val source = inputStream.source().buffer() - sink.use { - source.use { - while (isActive) { - val readCount = source.read(sink.buffer, SEGMENT_SIZE) - if (readCount == -1L) break + private suspend fun storeToCache(inputStream: InputStream, hint: String): File? = scope("storeToCache") { + withContext(Dispatchers.IO) { + runCatching { + storage.createFile()?.also { file -> + file.outputStream().buffered().use { output -> + inputStream.source().buffer().use { source -> + source.buffer.copyTo(output) } } } } - }.getOrNull() - } - - private companion object { - /** - * Matches [okio.Segment.SIZE] which is internal to okio. - */ - const val SEGMENT_SIZE = 8192L + .onFailure { + error("Error copying: $hint", it) + } + .getOrNull() + } } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/SelectedThreadRepository.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/SelectedThreadRepository.kt index 1ccaacd4..e32cdc02 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/SelectedThreadRepository.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/domain/SelectedThreadRepository.kt @@ -16,11 +16,10 @@ package com.nice.cxonechat.ui.domain import com.nice.cxonechat.ChatThreadHandler -import dagger.hilt.android.scopes.ActivityRetainedScoped -import javax.inject.Inject +import org.koin.core.annotation.Single -@ActivityRetainedScoped @Suppress("UseDataClass") -internal class SelectedThreadRepository @Inject constructor() { +@Single +internal class SelectedThreadRepository { var chatThreadHandler: ChatThreadHandler? = null } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/AudioRecordingViewModel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/AudioRecordingViewModel.kt index e614573b..048676da 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/AudioRecordingViewModel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/AudioRecordingViewModel.kt @@ -24,26 +24,37 @@ import android.os.Build import android.os.Environment import android.os.ParcelFileDescriptor import android.provider.MediaStore -import android.util.Log import androidx.annotation.RequiresPermission import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.error +import com.nice.cxonechat.log.scope +import com.nice.cxonechat.ui.PushListenerService +import com.nice.cxonechat.ui.UiModule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koin.android.annotation.KoinViewModel +import org.koin.core.qualifier.named +import org.koin.java.KoinJavaComponent.get import java.io.File import java.io.FileDescriptor import java.text.SimpleDateFormat -import java.util.* +import java.util.Date /** * ViewModel, which is responsible for providing state-based audio recording functionality. */ +@KoinViewModel internal class AudioRecordingViewModel : ViewModel() { + private val logger by lazy { LoggerScope(PushListenerService.TAG, get(Logger::class.java, named(UiModule.loggerName))) } + private val internalRecordingUriFlow: MutableStateFlow<Uri> = MutableStateFlow(Uri.EMPTY) private var filename: String? = null private var recorder: MediaRecorder? = null @@ -85,15 +96,17 @@ internal class AudioRecordingViewModel : ViewModel() { Manifest.permission.WRITE_EXTERNAL_STORAGE, ] ) - suspend fun startRecording(context: Context): Result<Unit> = runCatching { - val descriptor = getFileDescriptorAndSetUri(context) - val newRecorder = prepareRecording(context, descriptor.fileDescriptor) - fileDescriptor = descriptor - recorder = newRecorder - newRecorder.start() - internalRecordingFlow.value = true - }.onFailure { - Log.e(TAG, "startRecording: ", it) + suspend fun startRecording(context: Context): Result<Unit> = logger.scope("startRecording") { + runCatching { + val descriptor = getFileDescriptorAndSetUri(context) + val newRecorder = prepareRecording(context, descriptor.fileDescriptor) + fileDescriptor = descriptor + recorder = newRecorder + newRecorder.start() + internalRecordingFlow.value = true + }.onFailure { + error("startRecording: ", it) + } } private suspend fun getFileDescriptorAndSetUri(context: Context): ParcelFileDescriptor = withContext(Dispatchers.IO) { @@ -151,14 +164,7 @@ internal class AudioRecordingViewModel : ViewModel() { * @return [Uri] of the recorded audio file, if it is available. Missing uri indicates failure in the recording process. */ suspend fun stopRecording(context: Context): Boolean { - recorder?.apply { - runCatching { - stop() - release() - }.onFailure { - Log.e(TAG, "Failure during stopRecording().", it) - } - } + stopRecorder() recorder = null internalRecordingFlow.value = false filename = null @@ -168,6 +174,19 @@ internal class AudioRecordingViewModel : ViewModel() { return true } + /** + * Stops&releases the audio recorder and also logs potential exceptions. + */ + private fun stopRecorder() = logger.scope("stopRecorder") { + val mediaRecorder = recorder ?: return@scope + mediaRecorder.runCatching { + stop() + release() + }.onFailure { + error("Failure during stopRecording().", it) + } + } + override fun onCleared() { super.onCleared() recorder = null diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatStateViewModel.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatStateViewModel.kt index ddc2efbd..94a4335a 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatStateViewModel.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/main/ChatStateViewModel.kt @@ -18,20 +18,22 @@ package com.nice.cxonechat.ui.main import androidx.lifecycle.ViewModel import com.nice.cxonechat.ChatInstanceProvider import com.nice.cxonechat.ChatState -import dagger.hilt.android.lifecycle.HiltViewModel +import com.nice.cxonechat.exceptions.RuntimeChatException +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject +import kotlinx.coroutines.flow.callbackFlow +import org.koin.android.annotation.KoinViewModel /** * ViewModel responsible for providing [Flow] of [ChatState]. */ -@HiltViewModel -internal class ChatStateViewModel @Inject constructor( +@KoinViewModel +internal class ChatStateViewModel( private val chatInstanceProvider: ChatInstanceProvider, -) : ViewModel(), ChatInstanceProvider.Listener { +) : ViewModel() { private val internalState: MutableStateFlow<ChatState> = MutableStateFlow(chatInstanceProvider.chatState) private val providerListener = object : ChatInstanceProvider.Listener { override fun onChatStateChanged(chatState: ChatState) { @@ -39,6 +41,16 @@ internal class ChatStateViewModel @Inject constructor( } }.also(chatInstanceProvider::addListener) + val chatErrorState: Flow<RuntimeChatException> = callbackFlow { + val listener = object : ChatInstanceProvider.Listener { + override fun onChatRuntimeException(exception: RuntimeChatException) { + trySend(exception) + } + } + chatInstanceProvider.addListener(listener) + awaitClose { chatInstanceProvider.removeListener(listener) } + } + val state: StateFlow<ChatState> get() = internalState.asStateFlow() override fun onCleared() { 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 index d2834aa7..f16d48ad 100644 --- 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 @@ -35,73 +35,90 @@ import androidx.activity.result.contract.ActivityResultContracts.RequestMultiple 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.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.fragment.app.viewModels import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import com.google.android.material.snackbar.Snackbar.SnackbarLayout import com.google.gson.Gson -import com.nice.cxonechat.ui.R +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.SelectAttachmentsDialog import com.nice.cxonechat.ui.composable.conversation.model.ConversationUiState -import com.nice.cxonechat.ui.composable.conversation.model.Message.Attachment +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.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.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.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 dagger.hilt.android.AndroidEntryPoint +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 javax.inject.Inject -import com.nice.cxonechat.ui.composable.conversation.model.Message as UiMessage +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel /** * Fragment presenting UI of one concrete chat thread (conversation). */ @Suppress( - "TooManyFunctions" // Legacy for now + "TooManyFunctions", // Legacy for now + "LargeClass", ) -@AndroidEntryPoint class ChatThreadFragment : Fragment() { - private val viewModel: ChatThreadViewModel by viewModels() + private val chatViewModel: ChatThreadViewModel by viewModel() - private val audioViewModel: AudioRecordingViewModel by viewModels() - - private var fragmentBinding: FragmentChatThreadBinding? = null + private val audioViewModel: AudioRecordingViewModel by viewModel() private val activityLauncher by lazy { ActivityLauncher(requireActivity().activityResultRegistry) .also(lifecycle::addObserver) } - @Inject - internal lateinit var attachmentSharingRepository: AttachmentSharingRepository - private val requestPermissionLauncher: ActivityResultLauncher<String> = registerForActivityResult(RequestPermission()) { isGranted -> if (!isGranted) { AlertDialog.Builder(requireContext()) - .setTitle(R.string.no_notifications_title) - .setMessage(R.string.no_notifications_message) - .setNeutralButton(R.string.ok, null) + .setTitle(string.no_notifications_title) + .setMessage(string.no_notifications_message) + .setNeutralButton(string.ok, null) .show() } } @@ -111,14 +128,20 @@ class ChatThreadFragment : Fragment() { ) { requestResults: Map<String, Boolean>? -> if (requestResults.orEmpty().any { !it.value }) { MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.recording_audio_permission_denied_title) - .setMessage(R.string.recording_audio_permission_denied_body) - .setNeutralButton(R.string.ok) { dialog, _ -> + .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?, @@ -138,7 +161,7 @@ class ChatThreadFragment : Fragment() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { checkNotificationPermissions( Manifest.permission.POST_NOTIFICATIONS, - R.string.notifications_rationale + string.notifications_rationale ) } activityLauncher // activity launcher has to self-register before onStart @@ -170,7 +193,7 @@ class ChatThreadFragment : Fragment() { private fun registerOnPopupActionListener() { repeatOnViewOwnerLifecycle { - viewModel.actionState.filterIsInstance<ReceivedOnPopupAction>().collect { + chatViewModel.actionState.filterIsInstance<ReceivedOnPopupAction>().collect { val rawVariables = it.variables try { val variables = Gson().toJson(rawVariables) @@ -183,7 +206,11 @@ class ChatThreadFragment : Fragment() { val data = SnackbarSetupData(headingText, bodyText, actionText, actionUrl, it) showSnackBar(data) } catch (expected: Exception) { - Toast.makeText(requireContext(), "Unable to decode ReceivedOnPopupAction", Toast.LENGTH_SHORT).show() + Toast.makeText( + requireContext(), + "Unable to decode ReceivedOnPopupAction", + Toast.LENGTH_SHORT + ).show() } } } @@ -191,7 +218,7 @@ class ChatThreadFragment : Fragment() { private fun registerChatMetadataListener() { repeatOnViewOwnerLifecycle { - viewModel.chatMetadata.collect { chatData -> + chatViewModel.chatMetadata.collect { chatData -> activity?.title = chatData.threadName } } @@ -199,86 +226,165 @@ class ChatThreadFragment : Fragment() { private fun registerMessageListener() { repeatOnViewOwnerLifecycle { - val threadNameFlow = viewModel.chatMetadata.map { it.threadName } - fragmentBinding?.composeThreadView!!.setContent { - LaunchedEffect(key1 = viewModel) { - viewModel.refresh() - } + val threadNameFlow = chatViewModel.chatMetadata.map { it.threadName } - ChatTheme { - ChatConversation( - conversationState = ConversationUiState( - threadName = threadNameFlow, - sdkMessages = viewModel.messages, - typingIndicator = viewModel.agentState, - sendMessage = viewModel::sendMessage, - onClick = ::onMessageClick, - onLongClick = ::onMessageLongClick, - loadMore = viewModel::loadMore, - canLoadMore = viewModel.canLoadMore, - onStartTyping = ::onStartTyping, - onStopTyping = ::onStopTyping, - ), - audioRecordingState = AudioRecordingUiState( - uriFlow = audioViewModel.recordedUriFlow, - isRecordingFlow = audioViewModel.recordingFlow, - onDismiss = ::onDismissRecording, - onApprove = viewModel::sendAttachment, - onAudioRecordToggle = { onTriggerRecording() } - ), - onAttachmentTypeSelection = { activityLauncher.getContent(it) } - ) - } + fragmentBinding?.composeThreadView!!.setContent { + ContentView(threadNameFlow) + DialogView() } } } - private fun onMessageClick(message: UiMessage) { - if (message !is Attachment) return - val url = message.originalUrl - val mimeType = message.mimeType.orEmpty() - val directions = when { - mimeType.startsWith("image/") -> ChatThreadFragmentDirections.actionChatThreadFragmentToImagePreviewActivity( - url + @Composable + private fun DialogView() { + when (val dialog = chatViewModel.dialogShown.collectAsState(None).value) { + None -> Unit + CustomValues -> EditCustomValuesDialog( + title = stringResource(string.edit_custom_field_title), + fields = chatViewModel + .preChatSurvey + ?.fields + .orEmpty() + .mergeWithCustomField( + chatViewModel.customValues + ), + onCancel = chatViewModel::cancelEditingCustomValues, + onConfirm = chatViewModel::confirmEditingCustomValues + ) + 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, ) - mimeType.startsWith("video/") -> ChatThreadFragmentDirections.actionChatThreadFragmentToVideoPreviewActivity( - url + is ImageViewer -> ImageViewerDialogCard( + image = dialog.image, + title = dialog.title, + onDismiss = chatViewModel::dismissDialog, ) - else -> { - openWithAndroid(message) - return - } + is VideoPlayer -> VideoViewerDialogCard( + uri = dialog.uri, + title = dialog.title, + onDismiss = chatViewModel::dismissDialog + ) + } + + if (chatViewModel.preparingToShare.collectAsState().value) { + BusySpinner(message = stringResource(string.preparing)) + } + } + + @OptIn(ExperimentalComposeUiApi::class) + @Composable + private fun ContentView(threadNameFlow: Flow<String?>) { + LaunchedEffect(key1 = chatViewModel) { + chatViewModel.refresh() + } + + ChatTheme { + ChatConversation( + conversationState = ConversationUiState( + threadName = threadNameFlow, + sdkMessages = chatViewModel.messages, + typingIndicator = chatViewModel.agentState, + sendMessage = chatViewModel::sendMessage, + loadMore = chatViewModel::loadMore, + canLoadMore = chatViewModel.canLoadMore, + onStartTyping = ::onStartTyping, + onStopTyping = ::onStopTyping, + onAttachmentClicked = ::onAttachmentClicked, + onMoreClicked = ::onMoreClicked, + onShare = ::onShare, + isMultiThreaded = chatViewModel.isMultiThreadEnabled, + hasQuestions = chatViewModel.hasQuestions, + ), + audioRecordingState = AudioRecordingUiState( + uriFlow = audioViewModel.recordedUriFlow, + isRecordingFlow = audioViewModel.recordingFlow, + onDismiss = ::onDismissRecording, + onApprove = chatViewModel::sendAttachment, + onAudioRecordToggle = ::onTriggerRecording + ), + onAttachmentTypeSelection = activityLauncher::getContent, + onEditThreadName = ::showEditThreadName, + onEditThreadValues = ::showEditCustomValues, + modifier = Modifier.semantics { + testTagsAsResourceId = true // Enabled for UI test automation + } + ) } - findNavController().navigate(directions) } - private fun onMessageLongClick(message: UiMessage) { - if (message !is Attachment) return + private fun showEditThreadName() { + chatViewModel.editThreadName() + } + + private fun showEditCustomValues() { + chatViewModel.startEditingCustomValues() + } + + private fun onMoreClicked(attachments: List<Attachment>, title: String) { + chatViewModel.selectAttachments(attachments, title) + } + + private fun onShare(attachments: Collection<Attachment>) { + chatViewModel.beginPrepareAttachments() + val context = context ?: return - lifecycleScope.launch { - val intent = attachmentSharingRepository.createSharingIntent(message, context) - if (intent == null) { - Toast.makeText( - requireContext(), - "Unable to store attachment for sharing, please try again later", - Toast.LENGTH_SHORT - ).show() - } else { - startActivity(Intent.createChooser(intent, null)) + 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 openWithAndroid(message: Attachment) { + 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(message.originalUrl, message.mimeType)) { + if (!context.openWithAndroid(attachment.url, attachment.mimeType)) { AlertDialog.Builder(context) - .setTitle(R.string.unsupported_type_title) - .setMessage(getString(R.string.unsupported_type_message, message.mimeType)) - .setNegativeButton(R.string.cancel, null) + .setTitle(string.unsupported_type_title) + .setMessage(getString(string.unsupported_type_message, attachment.mimeType)) + .setNegativeButton(string.cancel, null) .show() } } @@ -291,12 +397,12 @@ class ChatThreadFragment : Fragment() { // TODO implement menu handling private fun onStartTyping() { - viewModel.reportThreadRead() - viewModel.reportTypingStarted() + chatViewModel.reportThreadRead() + chatViewModel.reportTypingStarted() } private fun onStopTyping() { - viewModel.reportTypingEnd() + chatViewModel.reportTypingEnd() } private fun showSnackBar(data: SnackbarSetupData) { @@ -306,7 +412,7 @@ class ChatThreadFragment : Fragment() { val snackBinding = CustomSnackBarBinding.inflate(layoutInflater, null, false) snackbar.view.setBackgroundColor(Color.TRANSPARENT) - val snackbarLayout = snackbar.view as SnackbarLayout + val snackbarLayout = snackbar.view as ViewGroup snackbarLayout.setPadding(0, 0, 0, 0) val headingTextView: TextView = snackBinding.headingTextView @@ -321,21 +427,21 @@ class ChatThreadFragment : Fragment() { val action = data.action actionTextView.setOnClickListener { - viewModel.reportOnPopupActionClicked(action) + chatViewModel.reportOnPopupActionClicked(action) // TODO build intent for the actionUrl - viewModel.reportOnPopupAction(SUCCESS, action) + chatViewModel.reportOnPopupAction(SUCCESS, action) snackbar.dismiss() } closeButton.setOnClickListener { - viewModel.reportOnPopupAction(FAILURE, action) + chatViewModel.reportOnPopupAction(FAILURE, action) snackbar.dismiss() } snackbarLayout.addView(snackBinding.root, 0) snackbar.show() - viewModel.reportOnPopupActionDisplayed(action) + chatViewModel.reportOnPopupActionDisplayed(action) } @SuppressLint( @@ -343,8 +449,9 @@ class ChatThreadFragment : Fragment() { ) private suspend fun onTriggerRecording(): Boolean { if (!checkPermissions( + valueStorage = valueStorage, permissions = requiredRecordAudioPermissions, - rationale = R.string.recording_audio_permission_rationale, + rationale = string.recording_audio_permission_rationale, onAcceptPermissionRequest = audioRequestPermissionLauncher::launch ) ) { @@ -363,16 +470,19 @@ class ChatThreadFragment : Fragment() { "MissingPermission" // permission state is checked by `checkPermissions()` method ) private fun onDismissRecording() { - if (!checkPermissions( - permissions = requiredRecordAudioPermissions, - rationale = R.string.recording_audio_permission_rationale, - onAcceptPermissionRequest = audioRequestPermissionLauncher::launch - ) - ) { - return - } - audioViewModel.deleteLastRecording(requireContext()) { - Toast.makeText(requireContext(), R.string.record_audio_failed_cleanup, Toast.LENGTH_LONG).show() + 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() + } } } @@ -410,7 +520,7 @@ class ChatThreadFragment : Fragment() { getContent = registry.register("key", owner, GetContent()) { uri -> val safeUri = uri ?: return@register - viewModel.sendAttachment(safeUri) + chatViewModel.sendAttachment(safeUri) } } 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 94ea8140..cb2f7171 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 @@ -35,20 +35,32 @@ import com.nice.cxonechat.message.MessageAuthor import com.nice.cxonechat.message.MessageDirection import com.nice.cxonechat.message.MessageDirection.ToAgent import com.nice.cxonechat.message.MessageMetadata +import com.nice.cxonechat.message.MessageStatus +import com.nice.cxonechat.message.MessageStatus.SENDING import com.nice.cxonechat.message.OutboundMessage +import com.nice.cxonechat.prechat.PreChatSurvey import com.nice.cxonechat.thread.Agent import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.CustomField +import com.nice.cxonechat.ui.customvalues.CustomValueItemList +import com.nice.cxonechat.ui.customvalues.extractStringValues import com.nice.cxonechat.ui.data.ContentDataSourceList import com.nice.cxonechat.ui.data.flow import com.nice.cxonechat.ui.domain.SelectedThreadRepository import com.nice.cxonechat.ui.main.ChatThreadViewModel.ChatMetadata.Companion.asMetadata +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.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.ReportOnPopupAction.CLICKED import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.DISPLAYED import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.FAILURE import com.nice.cxonechat.ui.main.ChatThreadViewModel.ReportOnPopupAction.SUCCESS -import dagger.hilt.android.lifecycle.HiltViewModel +import com.nice.cxonechat.ui.util.isEmpty +import com.nice.cxonechat.utilities.isEmpty import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -64,23 +76,31 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel import java.lang.ref.WeakReference -import java.util.* -import javax.inject.Inject +import java.util.Date +import java.util.UUID import com.nice.cxonechat.message.Message as SdkMessage @Suppress("TooManyFunctions") @OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -internal class ChatThreadViewModel @Inject constructor( +@KoinViewModel +internal class ChatThreadViewModel( private val contentDataSource: ContentDataSourceList, private val selectedThreadRepository: SelectedThreadRepository, private val chat: Chat, ) : ViewModel() { - - private val isMultiThreadEnabled = chat.configuration.hasMultipleThreadsPerEndUser - private val chatThreadHandler by lazy { selectedThreadRepository.chatThreadHandler!! } + private val threads by lazy { chat.threads() } + + val isMultiThreadEnabled = chat.configuration.hasMultipleThreadsPerEndUser + val preChatSurvey: PreChatSurvey? + get() = threads.preChatSurvey + val hasQuestions: Boolean + get() = preChatSurvey?.fields?.isEmpty() == false + private val chatThreadHandler = selectedThreadRepository.chatThreadHandler!! private val chatThreadFlow = chatThreadHandler.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) // Share incoming flow values + .filterNotNull() /** Tracks messages before they are confirmed as received by backend. */ private val sentMessagesFlow: MutableStateFlow<Map<UUID, SdkMessage>> = MutableStateFlow(emptyMap()) @@ -123,6 +143,43 @@ internal class ChatThreadViewModel @Inject constructor( val actionState: StateFlow<OnPopupActionState> = mutableActionState.asStateFlow() + val customValues: List<CustomField> + get() = selectedThreadRepository.chatThreadHandler?.get()?.fields ?: listOf() + + val selectedThreadName: String? + get() = selectedThreadRepository.chatThreadHandler?.get()?.threadName + + sealed interface Dialogs { + object None : Dialogs + object CustomValues : Dialogs + object EditThreadName : Dialogs + data class AudioPlayer( + val url: String, + val title: String?, + ) : Dialogs + data class SelectAttachments( + val attachments: List<Attachment>, + val title: String? + ) : Dialogs + + data class VideoPlayer( + val uri: String, + val title: String?, + ) : Dialogs + + data class ImageViewer( + val image: Any?, + val title: String?, + ) : Dialogs + } + + private val showDialog = MutableStateFlow<Dialogs>(None) + val dialogShown = showDialog.asStateFlow() + + // Note this is explicitly *not* part of Dialogs to allow it to be stacked over the select attachments dialog. + private val preparing = MutableStateFlow(false) + val preparingToShare = preparing.asStateFlow() + init { val listener = OnPopupActionListener { variables, metadata -> mutableActionState.value = ReceivedOnPopupAction(variables, metadata) @@ -135,6 +192,11 @@ internal class ChatThreadViewModel @Inject constructor( } fun sendMessage(message: OutboundMessage) { + // Ignore messages with no text, attachments, or postback. + if (message.attachments.isEmpty() && message.message.isBlank() && message.postback?.isBlank() == true) { + return + } + val appMessage: (UUID) -> SdkMessage = { id -> TemporarySentMessage( id = id, @@ -159,14 +221,14 @@ internal class ChatThreadViewModel @Inject constructor( } fun sendAttachment(attachment: Uri, message: String? = null) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { val contentDescriptor = contentDataSource.descriptorForUri(attachment) ?: return@launch val appMessage: (UUID) -> SdkMessage = { id -> TemporarySentMessage( id = id, attachment = object : Attachment { override val friendlyName: String = contentDescriptor.friendlyName ?: "unnamed" - override val mimeType: String = contentDescriptor.mimeType ?: "application/octet-stream" + override val mimeType: String = contentDescriptor.mimeType override val url: String = attachment.toString() }, text = message.orEmpty(), @@ -237,6 +299,67 @@ internal class ChatThreadViewModel @Inject constructor( super.onCleared() } + internal fun setThreadName(threadName: String) { + selectedThreadRepository.chatThreadHandler?.setName(threadName) + } + + private fun showDialog(dialog: Dialogs) { + showDialog.value = dialog + } + + internal fun dismissDialog() { + showDialog.value = Dialogs.None + } + + internal fun editThreadName() { + showDialog(EditThreadName) + } + + internal fun confirmEditThreadName(name: String) { + dismissDialog() + + setThreadName(name) + } + + internal fun startEditingCustomValues() { + showDialog(CustomValues) + } + + internal fun confirmEditingCustomValues(values: CustomValueItemList) { + dismissDialog() + viewModelScope.launch(Dispatchers.Default) { + chatThreadHandler.customFields().add(values.extractStringValues()) + } + } + + internal fun cancelEditingCustomValues() { + dismissDialog() + } + + internal fun playAudio(url: String, title: String?) { + showDialog(AudioPlayer(url, title)) + } + + internal fun selectAttachments(attachments: List<Attachment>, title: String?) { + showDialog(SelectAttachments(attachments, title)) + } + + internal fun beginPrepareAttachments() { + preparing.value = true + } + + internal fun finishPrepareAttachments() { + preparing.value = false + } + + internal fun showImage(image: Any, title: String?) { + showDialog(Dialogs.ImageViewer(image, title)) + } + + internal fun showVideo(url: String, title: String?) { + showDialog(Dialogs.VideoPlayer(url, title)) + } + sealed interface OnPopupActionState { object Empty : OnPopupActionState data class ReceivedOnPopupAction(val variables: Any, val metadata: ActionMetadata) : OnPopupActionState @@ -268,7 +391,7 @@ private data class TemporarySentMessage( override val createdAt: Date, override val direction: MessageDirection, override val metadata: MessageMetadata, - override val author: MessageAuthor, + override val author: MessageAuthor?, override val attachments: Iterable<Attachment>, override val fallbackText: String?, override val text: String, @@ -279,7 +402,9 @@ private data class TemporarySentMessage( createdAt = Date(), direction = ToAgent, metadata = object : MessageMetadata { + override val seenAt: Date? = null override val readAt: Date? = null + override val status: MessageStatus = SENDING }, author = object : MessageAuthor() { override val id: String = ChatThreadFragment.SENDER_ID @@ -298,7 +423,9 @@ private data class TemporarySentMessage( createdAt = Date(), direction = ToAgent, metadata = object : MessageMetadata { + override val seenAt: Date? = null override val readAt: Date? = null + override val status: MessageStatus = SENDING }, author = object : MessageAuthor() { override val id: String = ChatThreadFragment.SENDER_ID 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/main/ChatThreadsFragment.kt index b06ca4eb..9d26e990 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/main/ChatThreadsFragment.kt @@ -15,7 +15,6 @@ package com.nice.cxonechat.ui.main -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -65,7 +64,6 @@ 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.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle.State import androidx.navigation.fragment.findNavController import coil.compose.AsyncImage @@ -75,6 +73,8 @@ import com.nice.cxonechat.state.FieldDefinition import com.nice.cxonechat.state.FieldDefinitionList import com.nice.cxonechat.thread.Agent import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState +import com.nice.cxonechat.thread.ChatThreadState.Ready import com.nice.cxonechat.thread.CustomField import com.nice.cxonechat.ui.PreChatSurveyDialog import com.nice.cxonechat.ui.R.array @@ -89,6 +89,7 @@ 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.Initial import com.nice.cxonechat.ui.main.ChatThreadsViewModel.State.ThreadPreChatSurveyRequired import com.nice.cxonechat.ui.main.ChatThreadsViewModel.State.ThreadSelected @@ -99,23 +100,23 @@ 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel import java.util.UUID import kotlin.random.Random /** * Fragment displaying the list of available chat threads. */ -@AndroidEntryPoint class ChatThreadsFragment : Fragment() { - private val viewModel: ChatThreadsViewModel by activityViewModels() + private val chatThreadsViewModel: ChatThreadsViewModel by activityViewModel() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { repeatOnViewOwnerLifecycle(State.STARTED) { - viewModel.state.collect { state -> + chatThreadsViewModel.state.collect { state -> when (state) { /* normal state */ Initial -> Ignored @@ -132,35 +133,30 @@ class ChatThreadsFragment : Fragment() { return ComposeView(requireContext()).apply { setContent { ChatThreadsFragmentView( - state = viewModel.state.collectAsState().value, - threads = viewModel.threads.collectAsState().value, - threadFailure = viewModel.createThreadFailure.collectAsState().value, - onThreadSelected = viewModel::selectThread, - onCreateThread = viewModel::createThread, - onArchiveThread = viewModel::archiveThread, - resetState = viewModel::resetState, - respondToSurvey = viewModel::respondToSurvey, - resetCreateThreadState = viewModel::resetCreateThreadState + 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 onAttach(context: Context) { - super.onAttach(context) - - activity?.title = getString(string.thread_list_title) - } - override fun onResume() { super.onResume() - viewModel.refreshThreads() + activity?.title = getString(string.thread_list_title) + chatThreadsViewModel.refreshThreads() } private fun navigateToThread() { val destination = ChatThreadsFragmentDirections.actionChatThreadsFragmentToChat() findNavController().navigate(destination) - viewModel.resetState() + chatThreadsViewModel.resetState() } } @@ -178,6 +174,7 @@ private fun ChatThreadsFragmentView( ) { ChatTheme { ChatTheme.Scaffold( + topBar = { ChatTheme.TopBar(title = stringResource(id = string.thread_list_title)) }, floatingActionButton = { ChatTheme.Fab(rememberVectorPainter(image = Icons.Default.Add), null, onClick = onCreateThread) }, @@ -420,6 +417,7 @@ private data class PreviewThread( override val canAddMoreMessages: Boolean = true, override val scrollToken: String = "", override val fields: List<CustomField> = listOf(), + override val threadState: ChatThreadState = Ready, ) : ChatThread() { companion object { var nextThreadIndex: Int = 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 6c421eac..cc8000fb 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 @@ -19,11 +19,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nice.cxonechat.Chat import com.nice.cxonechat.ChatThreadEventHandlerActions.archiveThread -import com.nice.cxonechat.ChatThreadEventHandlerActions.loadMetadata +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.timedScope import com.nice.cxonechat.prechat.PreChatSurvey import com.nice.cxonechat.state.lookup import com.nice.cxonechat.state.validate import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.ui.UiModule import com.nice.cxonechat.ui.data.flow import com.nice.cxonechat.ui.domain.SelectedThreadRepository import com.nice.cxonechat.ui.main.ChatThreadsViewModel.State.Initial @@ -35,7 +38,6 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -47,6 +49,7 @@ import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.map @@ -55,40 +58,40 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.* -import javax.inject.Inject +import org.koin.android.annotation.KoinViewModel +import org.koin.core.qualifier.named +import org.koin.java.KoinJavaComponent.get +import java.util.UUID -@HiltViewModel +@KoinViewModel @Suppress( "TooManyFunctions" ) -internal class ChatThreadsViewModel @Inject constructor( +internal class ChatThreadsViewModel( private val chat: Chat, private val selectedThreadRepository: SelectedThreadRepository, private val valueStorage: ValueStorage, ) : ViewModel() { - + private val logger = LoggerScope(TAG, get(Logger::class.java, named(UiModule.loggerName))) private val threadsHandler = chat.threads() private val internalState: MutableStateFlow<State> = MutableStateFlow(Initial) - private val metadataRequested = mutableSetOf<UUID>() - val createThreadFailure = MutableStateFlow(null as Failure?) - private val threadList: StateFlow<List<Thread>> = threadsHandler - .flow + private val threadFlow = threadsHandler.flow + + private val threadList: StateFlow<List<Thread>> = threadFlow .conflate() .map { chatThreads -> chatThreads .asSequence() .map(::toUiThread) - .apply { scheduleLoadMetadata() } .toList() } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) private val threadUpdates = MutableStateFlow<List<Thread>>(emptyList()) - val isMultiThreadEnabled: Boolean = chat.configuration.hasMultipleThreadsPerEndUser + private val isMultiThreadEnabled: Boolean = chat.configuration.hasMultipleThreadsPerEndUser /** * Updated state of the chat threads view. @@ -97,7 +100,6 @@ internal class ChatThreadsViewModel @Inject constructor( get() = internalState .asStateFlow() - @OptIn(ExperimentalCoroutinesApi::class) val threads = merge(threadUpdates, threadList) .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) @@ -130,8 +132,7 @@ internal class ChatThreadsViewModel @Inject constructor( val chatThreadHandler = selectedThreadRepository.chatThreadHandler if ( chatThreadHandler == null || // No thread is selected - previousThreadsState.isEmpty() || // Previous threads state was not yet loaded - unable to compare - !metadataRequested.contains(chatThread.id) // Metadata update is not yet finished + previousThreadsState.isEmpty() // Previous threads state was not yet loaded - unable to compare ) { return false } @@ -151,15 +152,6 @@ internal class ChatThreadsViewModel @Inject constructor( } ) - private fun Sequence<Thread>.scheduleLoadMetadata() { - forEach { thread -> - if (!metadataRequested.contains(thread.id)) { - threadsHandler.thread(thread.chatThread).events().loadMetadata() - metadataRequested.add(thread.id) - } - } - } - internal fun resetState() { internalState.value = Initial } @@ -168,52 +160,62 @@ internal class ChatThreadsViewModel @Inject constructor( createThreadFailure.value = null } - internal fun createThread() { + internal fun createThread() = logger.timedScope("createThread") { viewModelScope.launch { when (val preChatSurvey = threadsHandler.preChatSurvey) { - null -> createThread(emptySequence()) + null -> createThreadWorker(emptySequence()) else -> internalState.value = ThreadPreChatSurveyRequired(preChatSurvey) } } } - internal fun respondToSurvey(response: Sequence<PreChatResponse>) { + internal fun respondToSurvey(response: Sequence<PreChatResponse>) = logger.timedScope("respondToSurvey") { viewModelScope.launch { - createThread(response) + createThreadWorker(response) } } - internal fun archiveThread(thread: Thread) = + internal fun archiveThread(thread: Thread) = logger.timedScope("archiveThread(${thread.id})") { threadsHandler.thread(thread.chatThread).events().archiveThread() + } - internal fun selectThread(thread: Thread) { + internal fun selectThread(thread: Thread) = logger.timedScope("selectThread(${thread.id})") { selectedThreadRepository.chatThreadHandler = threadsHandler.thread(thread.chatThread) internalState.value = ThreadSelected } - private suspend fun createThread(response: Sequence<PreChatResponse>) = withContext(Dispatchers.Default) { - val customerCustomFields = chat.configuration.customerCustomFields - val fields = valueStorage.getCustomerCustomValues().filter { - customerCustomFields.lookup(it.key) != null - } - val result = runCatching { - customerCustomFields.validate(fields) - chat.customFields().add(fields) - selectedThreadRepository.chatThreadHandler = threadsHandler.create(response) - }.foldToCreateThreadResult() - - if (result is Failure) { - createThreadFailure.value = result - } else { - internalState.value = ThreadSelected - } + internal suspend fun selectThreadById(threadId: UUID) = logger.timedScope("selectThreadById($threadId)") { + val flow = threadFlow + refreshThreads() + val threadList = flow.first() + require(threadList.isNotEmpty()) + selectedThreadRepository.chatThreadHandler = threadsHandler.thread(threadList.first { it.id == threadId }) + internalState.value = ThreadSelected } - internal fun refreshThreads() { - viewModelScope.launch { - metadataRequested.clear() - threadsHandler.refresh() + private suspend fun createThreadWorker(response: Sequence<PreChatResponse>) = + logger.timedScope("createThreadWorker") { + withContext(Dispatchers.Default) { + val customerCustomFields = chat.configuration.customerCustomFields + val fields = valueStorage.getCustomerCustomValues().filter { + customerCustomFields.lookup(it.key) != null + } + val result = runCatching { + customerCustomFields.validate(fields) + chat.customFields().add(fields) + selectedThreadRepository.chatThreadHandler = threadsHandler.create(response) + }.foldToCreateThreadResult() + + if (result is Failure) { + createThreadFailure.value = result + } else { + internalState.value = ThreadSelected + } + } } + + internal fun refreshThreads() = logger.timedScope("refreshThreads") { + threadsHandler.refresh() } /** @@ -226,7 +228,7 @@ internal class ChatThreadsViewModel @Inject constructor( object Initial : State /** - * Possible state following [createThread] call. + * Possible state following [createThreadWorker] call. * This state carries information about the prechat survey which should be presented to the user. * * @property survey The [PreChatSurvey] which should be presented to user. 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 13e448ad..c1006678 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 @@ -15,29 +15,25 @@ package com.nice.cxonechat.ui.main +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nice.cxonechat.Chat import com.nice.cxonechat.ChatEventHandlerActions.chatWindowOpen import com.nice.cxonechat.ChatInstanceProvider +import com.nice.cxonechat.ChatMode.MULTI_THREAD +import com.nice.cxonechat.ChatMode.SINGLE_THREAD import com.nice.cxonechat.prechat.PreChatSurvey -import com.nice.cxonechat.state.containsField -import com.nice.cxonechat.state.validate -import com.nice.cxonechat.thread.CustomField -import com.nice.cxonechat.ui.customvalues.CustomValueItemList -import com.nice.cxonechat.ui.customvalues.extractStringValues import com.nice.cxonechat.ui.data.flow import com.nice.cxonechat.ui.domain.SelectedThreadRepository -import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.CustomValues -import com.nice.cxonechat.ui.main.ChatViewModel.Dialogs.EditThreadName 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.SingleThreadCreationReady 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 @@ -45,24 +41,21 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers 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 javax.inject.Inject +import org.koin.android.annotation.KoinViewModel @Suppress("TooManyFunctions") -@HiltViewModel -internal class ChatViewModel @Inject constructor( +@KoinViewModel +internal class ChatViewModel( private val valueStorage: ValueStorage, private val selectedThreadRepository: SelectedThreadRepository, + private val chatProvider: ChatInstanceProvider, ) : ViewModel() { - private val chatProvider = ChatInstanceProvider.get() - - val chat: Chat + private val chat: Chat get() = requireNotNull(chatProvider.chat) private val threads by lazy { chat.threads() } @@ -71,16 +64,8 @@ internal class ChatViewModel @Inject constructor( private val internalState: MutableStateFlow<State> = MutableStateFlow(Initial) - val customValues: List<CustomField> - get() = selectedThreadRepository.chatThreadHandler?.get()?.fields ?: listOf() - - val selectedThreadName: String? - get() = selectedThreadRepository.chatThreadHandler?.get()?.threadName - sealed interface Dialogs { - object None : Dialogs - object CustomValues : Dialogs - object EditThreadName : Dialogs + data object None : Dialogs class Survey(val survey: PreChatSurvey) : Dialogs } @@ -88,7 +73,7 @@ internal class ChatViewModel @Inject constructor( val dialogShown = showDialog.asStateFlow() val isMultiThreadEnabled: Boolean - get() = chat.configuration.hasMultipleThreadsPerEndUser + get() = chat.chatMode == MULTI_THREAD val state get() = internalState @@ -111,12 +96,6 @@ internal class ChatViewModel @Inject constructor( } internal fun createThread() { - val preChatSurvey = threads.preChatSurvey - if (preChatSurvey != null) { - internalState.value = SingleThreadPreChatSurveyRequired(preChatSurvey) - return - } - createThread(emptySequence()) } @@ -144,31 +123,45 @@ internal class ChatViewModel @Inject constructor( private suspend fun resolveCurrentState(): State { if (internalState.value == NavigationFinished) return NavigationFinished - return when { - isMultiThreadEnabled -> MultiThreadEnabled - isFirstThread() -> SingleThreadCreationReady - else -> SingleThreadCreated + + return when (chat.chatMode) { + MULTI_THREAD -> MultiThreadEnabled + SINGLE_THREAD -> singleThreadChatState() } } - private suspend fun isFirstThread(): Boolean { + /** + * 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. + * + * Returns true iff the initial thread exists and was selected. + */ + private suspend fun selectFirstThread(): Boolean { val flow = threads.flow threads.refresh() - val threadList = flow.first() - val isFirst = threadList.isEmpty() - if (!isFirst) selectedThreadRepository.chatThreadHandler = threads.thread(threadList.first()) - return isFirst - } - private suspend fun setCustomFields() { - val customerCustomFields = chat.configuration.customerCustomFields - val fields = valueStorage.getCustomerCustomValues().filterKeys(customerCustomFields::containsField) - customerCustomFields.validate(fields) - chat.customFields().add(fields) + return flow.first().firstOrNull()?.let { + selectedThreadRepository.chatThreadHandler = threads.thread(it) + true + } ?: false } - internal fun setThreadName(threadName: String) { - selectedThreadRepository.chatThreadHandler?.setName(threadName) + private suspend fun setCustomFields() { + chatProvider.setCustomerValues(valueStorage.getCustomerCustomValues()) } internal fun reportOnResume() { @@ -179,41 +172,24 @@ internal class ChatViewModel @Inject constructor( showDialog.value = dialog } - internal fun dismissDialog() { + private fun dismissDialog() { showDialog.value = None } - internal fun editThreadName() { - showDialog(EditThreadName) - } - - internal fun confirmEditThreadName(name: String) { - dismissDialog() - - setThreadName(name) - } - - internal fun startEditingCustomValues() { - showDialog(CustomValues) - } - - internal fun confirmEditingCustomValues(values: CustomValueItemList) { - dismissDialog() - viewModelScope.launch(Dispatchers.Default) { - selectedThreadRepository.chatThreadHandler?.customFields()?.add(values.extractStringValues()) - } + internal fun showPreChatSurvey(survey: PreChatSurvey) { + showDialog(Survey(survey)) } - internal fun cancelEditingCustomValues() { - dismissDialog() + internal fun prepare(context: Context) { + chatProvider.prepare(context) } - internal fun showPreChatSurvey(survey: PreChatSurvey) { - showDialog(Survey(survey)) + internal fun connect() { + chatProvider.connect() } - internal fun reconnect() { - chatProvider.reconnect() + internal fun close() { + chatProvider.close() } /** @@ -223,18 +199,18 @@ internal class ChatViewModel @Inject constructor( /** * Navigation should be directed to multi-thread flow. */ - object MultiThreadEnabled : NavigationState + data object MultiThreadEnabled : NavigationState /** * Navigation should be directed to single thread chat. */ - object SingleThreadCreated : NavigationState + data object SingleThreadCreated : NavigationState /** * Final state of navigation, either the activity has successfully navigated to [MultiThreadEnabled] * state or [SingleThreadCreated] state. */ - object NavigationFinished : NavigationState + data object NavigationFinished : NavigationState } /** @@ -244,12 +220,12 @@ internal class ChatViewModel @Inject constructor( /** * Default state. */ - object Initial : State + data object Initial : State /** * Single thread is ready to be created. */ - object SingleThreadCreationReady : State + data object CreateSingleThread : State /** * Single thread creation requires prechat survey to be finished first, before thread can be created. diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/model/ChatThreadCopy.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/model/ChatThreadCopy.kt index cd2fa79c..e27881e8 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/model/ChatThreadCopy.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/model/ChatThreadCopy.kt @@ -18,6 +18,7 @@ package com.nice.cxonechat.ui.model import com.nice.cxonechat.message.Message import com.nice.cxonechat.thread.Agent import com.nice.cxonechat.thread.ChatThread +import com.nice.cxonechat.thread.ChatThreadState import com.nice.cxonechat.thread.CustomField import java.util.UUID @@ -32,6 +33,7 @@ internal data class ChatThreadCopy( override val scrollToken: String, override val threadAgent: Agent?, override val threadName: String?, + override val threadState: ChatThreadState, ) : ChatThread() { companion object { @Suppress("LongParameterList") @@ -43,6 +45,7 @@ internal data class ChatThreadCopy( scrollToken: String = this.scrollToken, threadAgent: Agent? = this.threadAgent, threadName: String? = this.threadName, + threadState: ChatThreadState = this.threadState, ): ChatThreadCopy = ChatThreadCopy( canAddMoreMessages = canAddMoreMessages, fields = fields, @@ -50,7 +53,8 @@ internal data class ChatThreadCopy( messages = messages, scrollToken = scrollToken, threadAgent = threadAgent, - threadName = threadName + threadName = threadName, + threadState = threadState, ) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/TemporaryFileStorage.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/TemporaryFileStorage.kt index 8e9c0a2f..23930804 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/TemporaryFileStorage.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/TemporaryFileStorage.kt @@ -16,20 +16,18 @@ package com.nice.cxonechat.ui.storage import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext +import org.koin.core.annotation.Single import java.io.File -import javax.inject.Inject -import javax.inject.Singleton +import java.util.UUID /** * This class manages storage of files which should be accessible via [TemporaryFileProvider]. */ -@Singleton +@Single internal class TemporaryFileStorage( context: Context, - private val baseDirectory: String?, + private val baseDirectory: String? = null, ) { - private val cacheDir: File by lazy { baseDirectory?.let(::File) ?: context.cacheDir } private val cacheFolder: File by lazy { val directory = File(cacheDir, "/tmp/") @@ -39,11 +37,11 @@ internal class TemporaryFileStorage( directory } - @Inject - constructor(@ApplicationContext context: Context) : this(context, null) - - fun createFile(name: String): File? { - val file = File(cacheFolder, name) + /** + * Creates a new temporary file with a randomly generated name. A [File] reference will be returned. + */ + fun createFile(): File? { + val file = File(cacheFolder, UUID.randomUUID().toString()) val fileCreated = runCatching { file.createNewFile() } return if (fileCreated.isSuccess) file else null } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorage.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorage.kt index 8b217ab3..a99a2480 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorage.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/storage/ValueStorage.kt @@ -23,15 +23,13 @@ import androidx.datastore.preferences.core.Preferences.Key import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import javax.inject.Inject -import javax.inject.Singleton +import org.koin.core.annotation.Single -@Singleton -internal class ValueStorage @Inject constructor( - @ApplicationContext private val context: Context, +@Single +internal class ValueStorage( + private val context: Context, ) { private val Context.storage: DataStore<Preferences> by preferencesDataStore(name = PREFERENCES_FILE_NAME) @@ -66,9 +64,11 @@ internal class ValueStorage @Inject constructor( private companion object { private const val PREFERENCES_FILE_NAME = "com.nice.cxonechat.ui.settings" private const val PREF_CUSTOM_VALUES: String = "share_custom_values_serialized" + private const val PREF_REQUESTED_PERMISSIONS: String = "ui_requested_permissions" } enum class StringKey(val value: Key<String>) { - CUSTOMER_CUSTOM_VALUES_KEY(stringPreferencesKey(PREF_CUSTOM_VALUES)) + CUSTOMER_CUSTOM_VALUES_KEY(stringPreferencesKey(PREF_CUSTOM_VALUES)), + REQUESTED_PERMISSIONS_KEY(stringPreferencesKey(PREF_REQUESTED_PERMISSIONS)) } } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/AttachmentExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/AttachmentExt.kt new file mode 100644 index 00000000..055afaaf --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/AttachmentExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021-2023. 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 com.nice.cxonechat.message.Attachment + +internal val Attachment.contentDescription: String? + get() = friendlyName.ifBlank { + runCatching { + Uri.parse(url).lastPathSegment + }.getOrNull() + } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ContextExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ContextExt.kt index b5c1cd0e..a8bde77c 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ContextExt.kt +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/ContextExt.kt @@ -15,7 +15,9 @@ package com.nice.cxonechat.ui.util +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.net.Uri import android.util.TypedValue @@ -49,3 +51,14 @@ internal fun Context.openWithAndroid(url: String, mimeType: String?): Boolean { true } } + +/** + * Look up parent activity recursively. + * + * @return Parent [Activity] or `null`. + */ +internal tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + !is ContextWrapper -> null + else -> baseContext.findActivity() +} 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/FragmentExt.kt index a48762f3..496879b7 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/FragmentExt.kt @@ -28,7 +28,10 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nice.cxonechat.ui.R +import com.nice.cxonechat.ui.storage.ValueStorage +import com.nice.cxonechat.ui.storage.ValueStorage.StringKey.REQUESTED_PERMISSIONS_KEY import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch /** @@ -66,39 +69,10 @@ internal fun Fragment.showRationale(@StringRes rationale: Int, onAcceptListener: .show() } -/** - * Requests all permissions, for which user can be shown request, otherwise opens dialog which will navigate the user to system settings - * of given application. - * This method is intended to be used with [checkPermissions] method. - * - * @param rationale Rationale to be shown to the user. - * @param requestablePermissions Permissions for which we can show user a request dialog. - * @param onAcceptListener action which will be called if user accepts our dialog with rationale for permission request. - * It will receive an array of requested permissions. - */ -private fun Fragment.askForPermissions( - @StringRes rationale: Int, - requestablePermissions: Collection<String>, - onAcceptListener: (Array<String>) -> Unit, -) { - if (requestablePermissions.isNotEmpty()) { - showRationale(rationale) { - onAcceptListener(requestablePermissions.toTypedArray()) - } - } else { - // User has pressed 'Deny & Don't ask again' for permission request in the past, - // we need to navigate him to settings. - showRationale(rationale) { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.fromParts("package", requireContext().packageName, null) - startActivity(intent) - } - } -} - /** * Checks if the required permissions are granted and requests user to grant them if they are not granted. * + * @param valueStorage Storage used to persist if the permission was already requested. * @param permissions Collection of required permissions * @param rationale Text with rationale which will explain user why we need said permissions. * @param onAcceptPermissionRequest Action which will be invoked when user accepts our request for permissions with an array of permissions @@ -107,21 +81,47 @@ private fun Fragment.askForPermissions( * * @return `true` if all permissions were already granted, otherwise `false`. */ -internal fun Fragment.checkPermissions( +internal suspend fun Fragment.checkPermissions( + valueStorage: ValueStorage, permissions: Iterable<String>, @StringRes rationale: Int, onAcceptPermissionRequest: (Array<String>) -> Unit, ): Boolean { - val missingPermissions = permissions.filterNot { permission -> + val missingPermissionsSet = permissions.filterNot { permission -> ContextCompat.checkSelfPermission(requireContext(), permission) == PackageManager.PERMISSION_GRANTED - } + }.toSet() + val missingPermissions = missingPermissionsSet.toTypedArray() val result = missingPermissions.isEmpty() if (!result) { - askForPermissions( - rationale = rationale, - requestablePermissions = missingPermissions.filter(::shouldShowRequestPermissionRationale), - onAcceptListener = onAcceptPermissionRequest - ) + if (missingPermissions.any(::shouldShowRequestPermissionRationale)) { + // User has previously declined permission request, show rationale why the permission is important. + showRationale( + rationale = rationale, + onAcceptListener = { onAcceptPermissionRequest(missingPermissions) } + ) + } else { + val requestedPermissions = valueStorage.getString(REQUESTED_PERMISSIONS_KEY) + .firstOrNull() + .orEmpty() + .split(", ") + .toSet() + if (missingPermissionsSet.intersect(requestedPermissions).isEmpty()) { + // Permission are requested for the first time + valueStorage.setString( + REQUESTED_PERMISSIONS_KEY, + requestedPermissions.union(missingPermissionsSet).joinToString(", ") + ) + onAcceptPermissionRequest(missingPermissions) + } else { + // Permissions were requested and the user has repeatedly denied the request. + // 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) + startActivity(intent) + } + } + } } return result } diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/SetExt.kt b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/SetExt.kt new file mode 100644 index 00000000..7945d94f --- /dev/null +++ b/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/util/SetExt.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021-2023. 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 + +/** + * Create a new set with the indicated node toggled. Ie., now included if it was + * not previously included or omitted if it was previously included. + * + * @param E type of elements contained in the set. + * @param item item state to toggle. + * @return a new set toggling the inclusion of [item] + */ +internal fun <E> Set<E>.toggle(item: E) = + if (contains(item)) { + this - item + } else { + this + item + } diff --git a/chat-sdk-ui/src/main/res/drawable/ic_baseline_cancel_24.xml b/chat-sdk-ui/src/main/res/drawable/ic_baseline_cancel_24.xml index 0ed7488c..31f249c8 100644 --- a/chat-sdk-ui/src/main/res/drawable/ic_baseline_cancel_24.xml +++ b/chat-sdk-ui/src/main/res/drawable/ic_baseline_cancel_24.xml @@ -1,3 +1,8 @@ +<!-- + ~ Apache license + ~ Material Symbols are available under the Apache License Version 2.0 + --> + <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:tint="#007AFF" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/chat-sdk-ui/src/main/res/drawable/ic_baseline_chat_24.xml b/chat-sdk-ui/src/main/res/drawable/ic_baseline_chat_24.xml index 6ba2fa78..91b8f5b2 100644 --- a/chat-sdk-ui/src/main/res/drawable/ic_baseline_chat_24.xml +++ b/chat-sdk-ui/src/main/res/drawable/ic_baseline_chat_24.xml @@ -1,3 +1,8 @@ +<!-- + ~ Apache license + ~ Material Symbols are available under the Apache License Version 2.0 + --> + <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:autoMirrored="true" android:tint="#FFFFFF" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/chat-sdk-ui/src/main/res/drawable/ic_baseline_edit.xml b/chat-sdk-ui/src/main/res/drawable/ic_baseline_edit.xml index 8b104a1e..2ef9e2bf 100644 --- a/chat-sdk-ui/src/main/res/drawable/ic_baseline_edit.xml +++ b/chat-sdk-ui/src/main/res/drawable/ic_baseline_edit.xml @@ -1,3 +1,8 @@ +<!-- + ~ Apache license + ~ Material Symbols are available under the Apache License Version 2.0 + --> + <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:tint="#FFFFFF" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/chat-sdk-ui/src/main/res/layout/activity_main.xml b/chat-sdk-ui/src/main/res/layout/activity_main.xml index ce8494e6..5807f593 100644 --- a/chat-sdk-ui/src/main/res/layout/activity_main.xml +++ b/chat-sdk-ui/src/main/res/layout/activity_main.xml @@ -21,24 +21,12 @@ android:orientation="vertical" tools:context=".ChatActivity"> - <androidx.appcompat.widget.Toolbar - android:id="@+id/my_toolbar" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_alignParentStart="true" - android:layout_alignParentTop="true" - android:layout_alignParentEnd="true" - android:background="@color/purple_700" - android:elevation="4dp" - android:theme="@style/ChatTheme.AppBarOverlay" - app:popupTheme="@style/ChatTheme.PopupOverlay"/> - <androidx.fragment.app.FragmentContainerView android:id="@+id/nav_host_fragment" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="0dp" - android:layout_below="@id/my_toolbar" + android:layout_alignParentTop="true" android:layout_alignParentStart="true" android:layout_alignParentEnd="true" android:layout_alignParentBottom="true" diff --git a/chat-sdk-ui/src/main/res/layout/activity_video_preview.xml b/chat-sdk-ui/src/main/res/layout/activity_video_preview.xml deleted file mode 100644 index cbec2030..00000000 --- a/chat-sdk-ui/src/main/res/layout/activity_video_preview.xml +++ /dev/null @@ -1,29 +0,0 @@ -<!-- - ~ Copyright (c) 2021-2023. 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. - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - tools:context=".VideoPreviewActivity"> - - <VideoView - android:id="@+id/videoView" - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" /> - -</LinearLayout> diff --git a/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 556936dd..8773c44a 100644 --- a/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -17,4 +17,4 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/> -</adaptive-icon> +</adaptive-icon> \ No newline at end of file diff --git a/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 556936dd..8773c44a 100644 --- a/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/chat-sdk-ui/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -17,4 +17,4 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/> -</adaptive-icon> +</adaptive-icon> \ No newline at end of file diff --git a/chat-sdk-ui/src/main/res/navigation/chat.xml b/chat-sdk-ui/src/main/res/navigation/chat.xml index e19202d5..60f1e393 100644 --- a/chat-sdk-ui/src/main/res/navigation/chat.xml +++ b/chat-sdk-ui/src/main/res/navigation/chat.xml @@ -25,32 +25,5 @@ android:name="com.nice.cxonechat.ui.main.ChatThreadFragment" android:label="ChatThreadFragment" tools:layout="@layout/fragment_chat_thread"> - <action - android:id="@+id/action_chatThreadFragment_to_imagePreviewActivity" - app:destination="@id/imagePreviewActivity" - app:launchSingleTop="true" - app:popUpTo="@id/chatThreadFragment" /> - <action - android:id="@+id/action_chatThreadFragment_to_videoPreviewActivity" - app:destination="@id/videoPreviewActivity" - app:launchSingleTop="true" - app:popUpTo="@id/chatThreadFragment" /> </fragment> - <activity - android:id="@+id/imagePreviewActivity" - android:name="com.nice.cxonechat.ui.ImagePreviewActivity" - android:label="activity_image_preview"> - <argument - android:name="imageUrl" - app:argType="string" /> - </activity> - <activity - android:id="@+id/videoPreviewActivity" - android:name="com.nice.cxonechat.ui.VideoPreviewActivity" - android:label="activity_video_preview" - tools:layout="@layout/activity_video_preview"> - <argument - android:name="videoUrl" - app:argType="string" /> - </activity> </navigation> diff --git a/chat-sdk-ui/src/main/res/values/ic_launcher_background.xml b/chat-sdk-ui/src/main/res/values/ic_launcher_background.xml index fbcb2ae4..3294d33e 100644 --- a/chat-sdk-ui/src/main/res/values/ic_launcher_background.xml +++ b/chat-sdk-ui/src/main/res/values/ic_launcher_background.xml @@ -16,4 +16,4 @@ <resources> <color name="ic_launcher_background">#FEFEFE</color> -</resources> +</resources> \ No newline at end of file diff --git a/chat-sdk-ui/src/main/res/values/strings.xml b/chat-sdk-ui/src/main/res/values/strings.xml index 87d5d1d5..1f13a881 100644 --- a/chat-sdk-ui/src/main/res/values/strings.xml +++ b/chat-sdk-ui/src/main/res/values/strings.xml @@ -89,6 +89,8 @@ <string name="chat_state_connection_lost_action_reconnect">Reconnect</string> <string name="chat_state_connection_closed">SDK closed</string> <string name="chat_state_connection_closed_action_restart">Restart</string> + <string name="chat_state_error_default_message">SDK has encountered error</string> + <string name="chat_state_error_action_close">Close Chat</string> <string name="edit_custom_field_title">Edit pre-chat survey custom fields</string> @@ -110,4 +112,24 @@ <string name="image_preview_title">Image preview</string> <string name="update_thread_name">Update Thread Name</string> <string name="enter_thread_name">Enter Thread Name</string> + <string name="default_thread_name">Thread</string> + <string name="video_preview_title">Video preview</string> + <string name="preparing_sdk">Preparing SDK</string> + <string name="default_agent_name">Agent</string> + <string name="default_customer_name">Me</string> + <string name="status_read">Read</string> + <string name="status_received">Received</string> + <string name="share_attachment_others">Share attachment %1$s +%2$d others</string> + <string name="share_attachment">Share attachment %1$s</string> + <string name="select_all">All</string> + <string name="select_none">None</string> + <string name="share_content_description">Share</string> + <string name="attachments_title">Attachments</string> + <string name="extra_attachments_count">+%1$s</string> + <string name="status_sent">Sent</string> + <string name="status_sending">Sending</string> + <string name="status_failed">Failed</string> + <string name="select_items">Select Items</string> + <string name="preparing">Preparing</string> + <string name="prepare_attachments_failure">Unable to prepare attachments for sharing, please try again later</string> </resources> diff --git a/config/detekt/detekt-common.yml b/config/detekt/detekt-common.yml index cbb51ce0..54b7e49b 100644 --- a/config/detekt/detekt-common.yml +++ b/config/detekt/detekt-common.yml @@ -50,6 +50,7 @@ complexity: active: true ignoreAnnotated: - "androidx.compose.runtime.Composable" + - "javax.inject.Inject" MethodOverloading: active: true NamedArguments: @@ -141,6 +142,7 @@ formatting: ArgumentListWrapping: active: true autoCorrect: true + maxLineLength: 140 excludes: [ '**/test/**', '**/androidTest/**', '**/*.Spec.kt' ] BlockCommentInitialStarAlignment: active: true @@ -332,6 +334,7 @@ formatting: Wrapping: active: true autoCorrect: true + maxLineLength: 140 naming: BooleanPropertyNaming: @@ -543,8 +546,6 @@ style: active: true OptionalUnit: active: false - OptionalWhenBraces: - active: true PreferToOverPairSyntax: active: true ProtectedMemberInFinalClass: diff --git a/docs/cs-coroutines.md b/docs/cs-coroutines.md index 10c3c458..cb24fcfe 100644 --- a/docs/cs-coroutines.md +++ b/docs/cs-coroutines.md @@ -10,8 +10,8 @@ described in the `dependencies` block, Major update revisions may vary in syntax ```groovy dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' - runtimeOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' + runtimeOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' } ``` diff --git a/docs/cs-instance-holder.md b/docs/cs-instance-holder.md index 4127acd6..ed0f1b00 100644 --- a/docs/cs-instance-holder.md +++ b/docs/cs-instance-holder.md @@ -9,7 +9,7 @@ follows "Google Suggested" practices. ```groovy dependencies { - implementation "androidx.lifecycle:lifecycle-common:2.5.1" + implementation "androidx.lifecycle:lifecycle-common:2.6.2" implementation "androidx.startup:startup-runtime:1.1.1" } ``` @@ -65,6 +65,11 @@ class ChatActivity : AppCompatActivity(R.layout.activity_chat), ChatInstanceProv "Chat SDK connected", Snackbar.LENGTH_SHORT ).apply(Snackbar::show) + READY -> chatStateSnackbar = Snackbar.make( + Window.DecorView.RootView, + "Chat SDK is ready", + Snackbar.LENGTH_SHORT + ).apply(Snackbar::show) CONNECTION_LOST -> chatStateSnackbar = Snackbar.make( Window.DecorView.RootView, "Chat SDK connection lost", diff --git a/docs/cs-single-thread.md b/docs/cs-single-thread.md index 573e1fef..f7fca2fb 100644 --- a/docs/cs-single-thread.md +++ b/docs/cs-single-thread.md @@ -54,9 +54,7 @@ class ChatConversationViewModel : ViewModel() { thread = it // notify ui } - handlerThread.refresh() // (1) } - handlerThreads.refresh() // (4) } override fun onCleared() { @@ -74,8 +72,6 @@ class ChatConversationViewModel : ViewModel() { - In this case all we care about is the first list - (3) Create or select a Thread - When Thread exists, then pick a thread otherwise create a new one -- (4) Fetch a current Thread representation - - Note the `refresh` call after setting a listener [cs-instance-holder]: cs-instance-holder.md diff --git a/docs/implementation.md b/docs/implementation.md index 78371d72..59aad892 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -17,10 +17,11 @@ been provided is heavily obfuscated to discourage you from using internal APIs. > Note that in every example, the instance of any given Handler is created at most once. > Make sure to follow suit. -## Proguard +## Proguard / R8 -There are no specific Proguard rules needed for this library. If there will be in the future, they -will be bundled with your aar and provided automatically with Maven. +There are specific Proguard rules needed for this library. +They are bundled with the chat-sdk-code aar and are provided automatically with Maven, alternatively they can be found in a file +[chat-sdk-core/consumer-rules.pro](../chat-sdk-core/consumer-rules.pro) and copied directly to your rule file. ## Setting Up @@ -52,12 +53,17 @@ val config = SocketFactoryConfiguration( 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 -> - // Chat instance will delivered in this callback once connection is established // TODO save chat instance } ``` @@ -73,19 +79,33 @@ cancellable = ChatBuilder(context, config) (if it can change in your application). > ℹ️ -> The `build` method asynchronously creates instance of chat which will be already connected -> to the backend. -> In case of connection error, the builder will schedule a connection retry attempt. -> Application can cancel this process according to its requirements via `Cancellable` instance -> returned from `build` method call. ---- - -Great! Now you're ready to use the CXone Chat SDK. +> 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. +> 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 > 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. +--- + +Now you can use the CXone Chat SDK for sending of analytics events (which are used for automation). +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 +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. + +Great! Now you're ready to use the CXone Chat SDK. + > ⚠️ Important note > Chat instance maintains open socket connection to backend, until `chat.close()` is called, or the > application process is terminated. @@ -98,7 +118,7 @@ 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 = MyChatInstanceProvide.chat ?: return +val chat = MyChatInstanceProvider.chat ?: return val chatConfiguration = chat.configuration ``` @@ -243,8 +263,7 @@ 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 should keep the instance as long as you project -> needing it. +> ⚠️ 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 @@ -253,7 +272,6 @@ threadsHandler.threads { // todo save the threads list // update ui } -threadsHandler.refresh() ``` > Note that we do not encourage a specific pattern as every application's code might be different. @@ -274,8 +292,7 @@ Use a thread list to fetch the first instance in the list OR create a new thread > 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 of type `MissingThreadListFetchException` in case you have forgotten to call the fetch, -> before the create call. +> or it can be of type `MissingThreadListFetchException` when you have called `create` before the chat has signaled `ChatStateListener.onReady()`. ```kotlin val threads: List<ChatThread> // stored somewhere @@ -350,8 +367,7 @@ defined by warnings and notes above, you're generally good to go. > ℹ️ > 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 not propagated as thread list update, but it will take effect on next refresh -> call. +> The cache update is also propagated as thread list update. --- @@ -464,6 +480,10 @@ messageHandler.send(listOf(descriptor)) > Note that such attachments will be stored in memory until they are uploaded to the server. > Be careful how large files you'll upload. +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 +instance of `RuntimeChatException.AttachmentUploadError` which will contain information about the cause and the +attachment filename. ### Load more Messages diff --git a/gradle.properties b/gradle.properties index 5ace1a8c..3ddb206f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,14 +20,13 @@ org.gradle.parallel=true kotlin.code.style=official # === Android === android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=false android.nonTransitiveRClass=true # === Dependencies with plugins === -kotlinVersion=1.8.21 -androidGradlePluginVersion=8.1.1 -detektVersion=1.23.0 -dokkaVersion=1.8.20 -daggerHiltVersion=2.45 +kotlinVersion=1.9.22 +androidGradlePluginVersion=8.2.2 +detektVersion=1.23.4 +dokkaVersion=1.9.10 # === Publishing === SONATYPE_HOST=DEFAULT RELEASE_SIGNING_ENABLED=false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832..c1962a79 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d01f7771..88013794 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionSha256Sum=03ec176d388f2aa99defcadc3ac6adf8dd2bce5145a129659537c0874dea5ad1 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb6..aeb74cbb 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -143,12 +140,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +194,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in diff --git a/gradlew.bat b/gradlew.bat index f127cfd4..93e3f59f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/libs/login-with-amazon-sdk.jar b/libs/login-with-amazon-sdk.jar index fbd200c6..a440f909 100644 Binary files a/libs/login-with-amazon-sdk.jar and b/libs/login-with-amazon-sdk.jar differ diff --git a/logger-android/.gitignore b/logger-android/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/logger-android/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/logger-android/README.MD b/logger-android/README.MD new file mode 100644 index 00000000..2546bc75 --- /dev/null +++ b/logger-android/README.MD @@ -0,0 +1,3 @@ +# About: + +The logger-android module provides default android specific implementation of logger. diff --git a/logger-android/api.txt b/logger-android/api.txt new file mode 100644 index 00000000..983ae103 --- /dev/null +++ b/logger-android/api.txt @@ -0,0 +1,10 @@ +// Signature format: 4.0 +package com.nice.cxonechat.log { + + public final class LoggerAndroid implements com.nice.cxonechat.log.Logger { + ctor public LoggerAndroid(String tag); + method public void log(com.nice.cxonechat.log.Level level, String message, Throwable? throwable); + } + +} + diff --git a/logger-android/build.gradle.kts b/logger-android/build.gradle.kts new file mode 100644 index 00000000..74b82426 --- /dev/null +++ b/logger-android/build.gradle.kts @@ -0,0 +1,44 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary + +/* + * Copyright (c) 2021-2023. 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. + */ + +plugins { + id("android-library-conventions") + id("android-kotlin-conventions") + id("android-docs-conventions") + id("android-test-conventions") + id("android-library-style-conventions") + id("publish-conventions") + id("api-conventions") +} + +android { + namespace = "com.nice.cxonechat.log.android" +} + +dependencies { + api(project(":logger")) +} + +mavenPublishing { + configure( + AndroidSingleVariantLibrary( + variant = "release", + sourcesJar = true, + publishJavadocJar = true, + ) + ) +} diff --git a/logger-android/consumer-rules.pro b/logger-android/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/logger-android/lint-baseline.xml b/logger-android/lint-baseline.xml new file mode 100644 index 00000000..f32fed49 --- /dev/null +++ b/logger-android/lint-baseline.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<issues format="6" by="lint 8.1.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.2)" variant="all" version="8.1.2"> + +</issues> diff --git a/logger-android/proguard-rules.pro b/logger-android/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/logger-android/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerAndroid.kt b/logger-android/src/main/java/com/nice/cxonechat/log/LoggerAndroid.kt similarity index 58% rename from chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerAndroid.kt rename to logger-android/src/main/java/com/nice/cxonechat/log/LoggerAndroid.kt index 9d9eacea..b6d5e9f4 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/LoggerAndroid.kt +++ b/logger-android/src/main/java/com/nice/cxonechat/log/LoggerAndroid.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.log import android.util.Log @@ -16,18 +31,20 @@ import android.util.Log * Logging is always performed unless platform refuses the logging operation via * [Log.isLoggable]. * - * See [Documentation](https://developer.android.com/reference/android/util/Log#isLoggable(java.lang.String,%20int)) + * See [Documentation](https://developer.android.com/reference/android/util/Log#isLoggable(kotlin.lang.String,%20int)) + * + * @param tag The tag which will be supplied when logging via [Log] for each logged message. * */ -internal class LoggerAndroid( - private val tag: String = "CXOneChat", +class LoggerAndroid( + private val tag: String, ) : Logger { private val Level.priority get() = when { - this >= Level.Severe -> Log.ERROR + this >= Level.Error -> Log.ERROR this >= Level.Warning -> Log.WARN this >= Level.Info -> Log.INFO - this >= Level.Config -> Log.DEBUG + this >= Level.Debug -> Log.DEBUG else -> Log.VERBOSE } diff --git a/logger/.gitignore b/logger/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/logger/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/logger/README.MD b/logger/README.MD new file mode 100644 index 00000000..48dd5520 --- /dev/null +++ b/logger/README.MD @@ -0,0 +1,3 @@ +# About: + +The logger module is minimalistic logging framework used by the chat-sdk-core without any platform specific code. diff --git a/logger/api.txt b/logger/api.txt new file mode 100644 index 00000000..fbc6b3e8 --- /dev/null +++ b/logger/api.txt @@ -0,0 +1,117 @@ +// Signature format: 4.0 +package com.nice.cxonechat.log { + + public abstract sealed class Level { + method public operator int compareTo(com.nice.cxonechat.log.Level other); + method public abstract int getIntValue(); + property public abstract int intValue; + } + + public static final class Level.All extends com.nice.cxonechat.log.Level { + method public int getIntValue(); + property public int intValue; + field public static final com.nice.cxonechat.log.Level.All INSTANCE; + } + + public static final class Level.Custom extends com.nice.cxonechat.log.Level { + ctor public Level.Custom(int intValue); + method public int getIntValue(); + property public int intValue; + } + + public static final class Level.Debug extends com.nice.cxonechat.log.Level { + method public int getIntValue(); + property public int intValue; + field public static final com.nice.cxonechat.log.Level.Debug INSTANCE; + } + + public static final class Level.Error extends com.nice.cxonechat.log.Level { + method public int getIntValue(); + property public int intValue; + field public static final com.nice.cxonechat.log.Level.Error INSTANCE; + } + + public static final class Level.Info extends com.nice.cxonechat.log.Level { + method public int getIntValue(); + property public int intValue; + field public static final com.nice.cxonechat.log.Level.Info INSTANCE; + } + + public static final class Level.Verbose extends com.nice.cxonechat.log.Level { + method public int getIntValue(); + property public int intValue; + field public static final com.nice.cxonechat.log.Level.Verbose INSTANCE; + } + + public static final class Level.Warning extends com.nice.cxonechat.log.Level { + method public int getIntValue(); + property public int intValue; + field public static final com.nice.cxonechat.log.Level.Warning INSTANCE; + } + + public final class LevelTest { + ctor public LevelTest(); + method public void all_hasLevelMinValue(); + method public void compareTo_returnsValidInteger(); + method public void custom_hasLevelUnmodified(); + method public void debug_hasLevel400(); + method public void error_hasLevel1000(); + method public void info_hasLevel800(); + method public void verbose_hasLevel300(); + method public void verify_order(); + method public void warning_hasLevel900(); + } + + public interface Logger { + method public void log(com.nice.cxonechat.log.Level level, String message, optional Throwable? throwable); + } + + public final class LoggerExtKt { + method public static void debug(com.nice.cxonechat.log.Logger, String message, optional Throwable? throwable); + method public static inline <T> T duration(com.nice.cxonechat.log.Logger, kotlin.jvm.functions.Function0<? extends T> body); + method public static void error(com.nice.cxonechat.log.Logger, String message, optional Throwable? throwable); + method public static void info(com.nice.cxonechat.log.Logger, String message, optional Throwable? throwable); + method public static void verbose(com.nice.cxonechat.log.Logger, String message, optional Throwable? throwable); + method public static void warning(com.nice.cxonechat.log.Logger, String message, optional Throwable? throwable); + } + + public final class LoggerNoop implements com.nice.cxonechat.log.Logger { + method public void log(com.nice.cxonechat.log.Level level, String message, Throwable? throwable); + field public static final com.nice.cxonechat.log.LoggerNoop INSTANCE; + } + + public interface LoggerScope extends com.nice.cxonechat.log.Logger { + method public com.nice.cxonechat.log.Logger getIdentity(); + method public String getScope(); + property public abstract com.nice.cxonechat.log.Logger identity; + property public abstract String scope; + field public static final com.nice.cxonechat.log.LoggerScope.Companion Companion; + } + + public static final class LoggerScope.Companion { + method public inline operator <reified T> com.nice.cxonechat.log.LoggerScope! invoke(com.nice.cxonechat.log.Logger identity); + method public operator com.nice.cxonechat.log.LoggerScope invoke(String name, com.nice.cxonechat.log.Logger identity); + } + + public final class LoggerScopeKt { + method public static inline <T> T scope(com.nice.cxonechat.log.LoggerScope, String name, kotlin.jvm.functions.Function1<? super com.nice.cxonechat.log.LoggerScope,? extends T> body); + method public static inline <T> T timedScope(com.nice.cxonechat.log.LoggerScope, String name, kotlin.jvm.functions.Function0<? extends T> body); + } + + public final class ProxyLogger implements com.nice.cxonechat.log.Logger { + ctor public ProxyLogger(com.nice.cxonechat.log.Logger... loggers); + ctor public ProxyLogger(optional Iterable<? extends com.nice.cxonechat.log.Logger> initialLoggers); + method public void add(com.nice.cxonechat.log.Logger logger); + method public void add(com.nice.cxonechat.log.Logger... loggers); + method public void addAll(Iterable<? extends com.nice.cxonechat.log.Logger> loggers); + method public void clear(); + method public int getLoggerCount(); + method public java.util.List<com.nice.cxonechat.log.Logger> getLoggers(); + method public void log(com.nice.cxonechat.log.Level level, String message, Throwable? throwable); + method public void remove(com.nice.cxonechat.log.Logger logger); + property public final int loggerCount; + property public final java.util.List<com.nice.cxonechat.log.Logger> loggers; + } + +} + diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageAuthorDefaults.kt b/logger/build.gradle.kts similarity index 56% rename from chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageAuthorDefaults.kt rename to logger/build.gradle.kts index c4acca3a..15a02048 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/internal/model/MessageAuthorDefaults.kt +++ b/logger/build.gradle.kts @@ -1,3 +1,6 @@ +import com.vanniktech.maven.publish.JavaLibrary +import com.vanniktech.maven.publish.JavadocJar + /* * Copyright (c) 2021-2023. NICE Ltd. All rights reserved. * @@ -13,24 +16,22 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -package com.nice.cxonechat.internal.model - -import com.nice.cxonechat.message.MessageAuthor -import java.util.UUID - -internal object MessageAuthorDefaults { +plugins { + id("java-library-conventions") + id("jvm-kotlin-conventions") + id("library-style-conventions") + id("test-conventions") + id("docs-conventions") + id("publish-conventions") + id("api-conventions") +} - val User: MessageAuthor = MessageAuthorInternal( - id = UUID.randomUUID().toString(), - firstName = "Unknown", - lastName = "Customer", - imageUrl = null, +mavenPublishing { + configure( + JavaLibrary( + javadocJar = JavadocJar.Javadoc(), + // whether to publish a sources jar + sourcesJar = true, ) - - val Agent: MessageAuthor = MessageAuthorInternal( - id = "0", - firstName = "Automated", - lastName = "Agent", - imageUrl = null, ) } diff --git a/logger/src/main/kotlin/com/nice/cxonechat/log/Level.kt b/logger/src/main/kotlin/com/nice/cxonechat/log/Level.kt new file mode 100644 index 00000000..75725754 --- /dev/null +++ b/logger/src/main/kotlin/com/nice/cxonechat/log/Level.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2021-2023. 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.log + +/** + * Logging level. + */ +sealed class Level : Comparable<Level> { + + /** Integer representation of a given level. */ + abstract val intValue: Int + + /** Compares itself to [other] level for convenience. */ + override operator fun compareTo(other: Level): Int = intValue.compareTo(other.intValue) + + /** Error level corresponds to integer value of 1000. */ + data object Error : Level() { + override val intValue: Int + get() = 1000 + } + + /** Warning level corresponds to integer value of 900. */ + data object Warning : Level() { + override val intValue: Int + get() = 900 + } + + /** Info level corresponds to integer value of 800. */ + data object Info : Level() { + override val intValue: Int + get() = 800 + } + + /** Debug level corresponds to integer value of 400. */ + data object Debug : Level() { + override val intValue: Int + get() = 400 + } + + /** Verbose level corresponds to integer value of 300. */ + data object Verbose : Level() { + override val intValue: Int + get() = 300 + } + + /** + * All level corresponds to integer value of [Int.MIN_VALUE]. + * Note that this option may not be supported by all Logger + * implementations. + * */ + data object All : Level() { + override val intValue: Int + get() = Int.MIN_VALUE + } + + /** + * Custom level allows you to specify your own values and + * store them statically. + * */ + class Custom( + override val intValue: Int, + ) : Level() +} diff --git a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/Logger.kt b/logger/src/main/kotlin/com/nice/cxonechat/log/Logger.kt similarity index 56% rename from chat-sdk-core/src/main/java/com/nice/cxonechat/log/Logger.kt rename to logger/src/main/kotlin/com/nice/cxonechat/log/Logger.kt index 6f37d99e..12549923 100644 --- a/chat-sdk-core/src/main/java/com/nice/cxonechat/log/Logger.kt +++ b/logger/src/main/kotlin/com/nice/cxonechat/log/Logger.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2021-2023. 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.log /** diff --git a/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerExt.kt b/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerExt.kt new file mode 100644 index 00000000..33c3fe68 --- /dev/null +++ b/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerExt.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021-2023. 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.log + +import com.nice.cxonechat.log.Level.Debug +import com.nice.cxonechat.log.Level.Error +import com.nice.cxonechat.log.Level.Info +import com.nice.cxonechat.log.Level.Verbose +import com.nice.cxonechat.log.Level.Warning +import kotlin.time.DurationUnit.MILLISECONDS +import kotlin.time.measureTimedValue + +/** + * Calls [Logger.log] message with [Verbose] level. + * + * @param message Message to log. + * @param throwable Optional [Throwable] to log, default `null`. + */ +fun Logger.verbose(message: String, throwable: Throwable? = null) { + log(Verbose, message, throwable) +} + +/** + * Calls [Logger.log] message with [Debug] level. + * + * @param message Message to log. + * @param throwable Optional [Throwable] to log, default `null`. + */ +fun Logger.debug(message: String, throwable: Throwable? = null) { + log(Debug, message, throwable) +} + +/** + * Calls [Logger.log] message with [Info] level. + * + * @param message Message to log. + * @param throwable Optional [Throwable] to log, default `null`. + */ +fun Logger.info(message: String, throwable: Throwable? = null) { + log(Info, message, throwable) +} + +/** + * Calls [Logger.log] message with [Warning] level. + * + * @param message Message to log. + * @param throwable Optional [Throwable] to log, default `null`. + */ +fun Logger.warning(message: String, throwable: Throwable? = null) { + log(Warning, message, throwable) +} + +/** + * Calls [Logger.log] message with [Error] level. + * + * @param message Message to log. + * @param throwable Optional [Throwable] to log, default `null`. + */ +fun Logger.error(message: String, throwable: Throwable? = null) { + log(Error, message, throwable) +} + +/** + * Measures the duration it takes to invoke the [body]. + * Logs with [Verbose] level message `Started` before the invocation and `Finished took XYZms` after completion. + * + * @param T Result type of [body] execution. + * @param body Function which should be measured. + * @return Result of [body] execution. + */ +inline fun <T> Logger.duration(body: () -> T): T { + verbose("Started") + val (value, duration) = measureTimedValue(body) + verbose("Finished took ${duration.toDouble(MILLISECONDS)}ms") + return value +} diff --git a/chat-sdk-ui/src/test/java/com/nice/cxonechat/sample/ExampleUnitTest.kt b/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerNoop.kt similarity index 64% rename from chat-sdk-ui/src/test/java/com/nice/cxonechat/sample/ExampleUnitTest.kt rename to logger/src/main/kotlin/com/nice/cxonechat/log/LoggerNoop.kt index 64708306..86687641 100644 --- a/chat-sdk-ui/src/test/java/com/nice/cxonechat/sample/ExampleUnitTest.kt +++ b/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerNoop.kt @@ -13,19 +13,13 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -package com.nice.cxonechat.sample - -import org.junit.Assert.assertEquals -import org.junit.Test +package com.nice.cxonechat.log /** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). + * A [Logger] instance that prevents all logging. */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) +object LoggerNoop : Logger { + override fun log(level: Level, message: String, throwable: Throwable?) { + /* no-op */ } } diff --git a/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerScope.kt b/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerScope.kt new file mode 100644 index 00000000..87946254 --- /dev/null +++ b/logger/src/main/kotlin/com/nice/cxonechat/log/LoggerScope.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021-2023. 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.log + +/** + * Wrapper for [Logger] which prepends each logged message with the [scope]. + */ +interface LoggerScope : Logger { + /** Description of the scope, usually a class name or a custom name. */ + val scope: String + + /** The wrapped [Logger]. */ + val identity: Logger + + companion object { + + /** + * Creates an instance of [LoggerScope] with custom [name]. + * + * @param name The name of the scope. + * @param identity The wrapped [Logger]. + */ + operator fun invoke(name: String, identity: Logger): LoggerScope = NamedScope(scope = name, identity = identity) + + /** + * Creates an instance of [LoggerScope] with [scope] set to class simple name. + * + * @param T The type which will be used for naming of the [LoggerScope]. + * @param identity The wrapped [Logger]. + */ + inline operator fun <reified T> invoke(identity: Logger) = + LoggerScope(T::class.java.simpleName, identity) + } +} + +/** + * The default implementation of [LoggerScope] if custom naming of the scope is needed. + * + * @param scope The custom name of the scope. + * @param identity The wrapped [Logger]. + */ +private class NamedScope( + override val scope: String, + override val identity: Logger, +) : LoggerScope { + + override fun log(level: Level, message: String, throwable: Throwable?) { + identity.log(level, "[$scope] $message", throwable) + } +} + +/** + * Creates temporary sub-scope of current [LoggerScope] with custom [name] post-fixed to the new [scope]. + * The sub-scope is discarded once the [body] invocation is finished. + * + * @param T The result type of the scoped function. + * @param name Name of custom sub-scope. + * @param body Function which defines the sub-scope. + * @return The result of the [body] invocation. + */ +inline fun <T> LoggerScope.scope(name: String, body: LoggerScope.() -> T): T = + LoggerScope("$scope/$name", identity).body() + +/** + * Measures the duration of a scope. + * + * Shorthand for scope(name) { duration { body }}. + * + * @param T The result type of the scoped function. + * @param name Name of custom sub-scope. + * @param body Function which defines the sub-scope. + * @return The result of the [body] invocation. + */ +inline fun <T> LoggerScope.timedScope(name: String, body: () -> T): T = scope(name) { + duration(body) +} diff --git a/logger/src/main/kotlin/com/nice/cxonechat/log/ProxyLogger.kt b/logger/src/main/kotlin/com/nice/cxonechat/log/ProxyLogger.kt new file mode 100644 index 00000000..d18b7925 --- /dev/null +++ b/logger/src/main/kotlin/com/nice/cxonechat/log/ProxyLogger.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2021-2023. 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.log + +import java.util.Collections + +/** + * [Logger] implementation which calls all registered [Logger]s when this instance [log] method is called. + * + * @param initialLoggers Initial [Iterable] of loggers to be used by the [ProxyLogger]. + */ +class ProxyLogger( + initialLoggers: Iterable<Logger> = emptyList(), +) : Logger { + + private val loggerList = initialLoggers.toMutableList() + + /** Returns count of currently registered [Logger]s. */ + val loggerCount + get() = synchronized(loggerList) { loggerList.size } + + /** Returns unmodifiable copy of registered [Logger]s. */ + val loggers: List<Logger> + get() = synchronized(loggerList) { loggerList.toList() } + + /** + * Creates an instance of [ProxyLogger] using supplied [loggers]. + * + * @param loggers An array of loggers to be used by the [ProxyLogger]. + */ + constructor( + vararg loggers: Logger, + ) : this(loggers.asIterable()) + + /** + * Add a new logger. + */ + fun add(logger: Logger) { + require(logger !== this) { ERR_CANT_ADD_SELF } + return synchronized(loggerList) { + loggerList.add(logger) + } + } + + /** Add new loggers. */ + fun add(vararg loggers: Logger) { + for (logger in loggers) { + require(logger !== this) { ERR_CANT_ADD_SELF } + } + synchronized(loggerList) { + Collections.addAll(loggerList, *loggers) + } + } + + /** Add new loggers. */ + fun addAll(loggers: Iterable<Logger>) { + require(loggers.none { it === this }) { ERR_CANT_ADD_SELF } + synchronized(loggerList) { + loggerList.addAll(loggers) + } + } + + /** Remove given logger. */ + fun remove(logger: Logger) { + synchronized(loggerList) { + loggerList.remove(logger) + } + } + + /** Remove all loggers. */ + fun clear() { + synchronized(loggerList, loggerList::clear) + } + + override fun log(level: Level, message: String, throwable: Throwable?) { + synchronized(loggerList) { + for (logger in loggerList) { + logger.log(level, message, throwable) + } + } + } + + private companion object { + private const val ERR_CANT_ADD_SELF = "Can't add self." + } +} diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/LevelTest.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/LevelTest.kt new file mode 100644 index 00000000..3ef2cca3 --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/LevelTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2021-2023. 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.log + +import com.nice.cxonechat.log.Level.All +import com.nice.cxonechat.log.Level.Debug +import com.nice.cxonechat.log.Level.Error +import com.nice.cxonechat.log.Level.Info +import com.nice.cxonechat.log.Level.Verbose +import com.nice.cxonechat.log.Level.Warning +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class LevelTest { + + @Test + fun error_hasLevel1000() { + val level = Error + assertEquals(1000, level.intValue) + } + + @Test + fun warning_hasLevel900() { + val level = Warning + assertEquals(900, level.intValue) + } + + @Test + fun info_hasLevel800() { + val level = Info + assertEquals(800, level.intValue) + } + + @Test + fun debug_hasLevel400() { + val level = Debug + assertEquals(400, level.intValue) + } + + @Test + fun verbose_hasLevel300() { + val level = Verbose + assertEquals(300, level.intValue) + } + + @Test + fun all_hasLevelMinValue() { + val level = All + assertEquals(Int.MIN_VALUE, level.intValue) + } + + @Test + fun custom_hasLevelUnmodified() { + val level = Level.Custom(154) + assertEquals(154, level.intValue) + } + + @Test + fun compareTo_returnsValidInteger() { + assert(Level.Custom(0) < Level.Custom(400)) { + "compareTo returned invalid value for comparison. It's expected that 0 < 400" + } + } + + @Test + fun verify_order() { + val list = mutableListOf( + All, + Info, + Debug, + Verbose, + Error, + Warning, + ) + .also(MutableList<Level>::sort) + .toList() + val expected = listOf( + All, + Verbose, + Debug, + Info, + Warning, + Error, + ) + assertContentEquals(expected, list) + } +} diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerExtTest.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerExtTest.kt new file mode 100644 index 00000000..034c279d --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerExtTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2021-2023. 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.log + +import com.nice.cxonechat.log.Level.Info +import com.nice.cxonechat.log.Level.Verbose +import com.nice.cxonechat.log.fake.CollectingLogger +import com.nice.cxonechat.log.fake.LoggedMessage +import com.nice.cxonechat.log.tool.nextString +import org.junit.Test +import kotlin.reflect.KFunction2 +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class LoggerExtTest { + @Test + fun test_verbose() { + val logger = CollectingLogger() + val level = Verbose + val methodToTest = logger::verbose + testMethod(logger, level, methodToTest) + } + + @Test + fun test_debug() { + val logger = CollectingLogger() + val level = Level.Debug + val methodToTest = logger::debug + testMethod(logger, level, methodToTest) + } + + @Test + fun test_info() { + val logger = CollectingLogger() + val level = Info + val methodToTest = logger::info + testMethod(logger, level, methodToTest) + } + + @Test + fun test_warning() { + val logger = CollectingLogger() + val level = Level.Warning + val methodToTest = logger::warning + testMethod(logger, level, methodToTest) + } + + @Test + fun test_error() { + val logger = CollectingLogger() + val level = Level.Error + val methodToTest = logger::error + testMethod(logger, level, methodToTest) + } + + @Test + fun test_duration() { + val logger = CollectingLogger() + val expectedResult = 42L + val expectedMessages: List<LoggedMessage> = listOf( + LoggedMessage(Verbose, "Started", null), + LoggedMessage(Info, "Going to sleep", null), + LoggedMessage(Info, "Awake", null), + LoggedMessage(Verbose, "Finished", null), + ) + val result = logger.duration { + logger.log(expectedMessages[1].level, expectedMessages[1].message) + Thread.sleep(expectedResult) + logger.log(expectedMessages[2].level, expectedMessages[2].message) + expectedResult + } + assertEquals(expectedResult, result) + assertEquals(expectedMessages.size, logger.logged.size) + expectedMessages.forEachIndexed { index, triple -> + val (expectedLevel, expectedMessage, _) = triple + val (level, message, _) = logger.logged[index] + assertEquals(expectedLevel, level) + assertTrue(message.startsWith(expectedMessage)) + } + } + + private fun testMethod(logger: CollectingLogger, level: Level, methodToTest: KFunction2<String, Throwable?, Unit>) { + val listOfExpected: List<LoggedMessage> = listOf( + LoggedMessage(level, nextString(), RuntimeException(nextString())), + LoggedMessage(level, nextString(), null) + ) + listOfExpected.forEach { (_, message, throwable) -> methodToTest(message, throwable) } + assertEquals(listOfExpected, logger.logged) + } +} diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerScopeTest.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerScopeTest.kt new file mode 100644 index 00000000..d1910885 --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerScopeTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2021-2023. 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.log + +import com.nice.cxonechat.log.fake.CollectingLogger +import com.nice.cxonechat.log.tool.nextString +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class LoggerScopeTest { + + @Test + fun testClassNamedScope() { + val identity = CollectingLogger() + val loggerScope = object : LoggerScope by LoggerScope<LoggerScopeTest>(identity) {} + assertEquals(LoggerScopeTest::class.java.simpleName, loggerScope.scope) + assertEquals(identity, loggerScope.identity) + val level = Level.Info + val message = nextString() + loggerScope.log(level, message) + val logged = identity.logged.first() + assertEquals(level, logged.level) + assertTrue { + logged.message.contains(".*(${loggerScope.scope}).*($message)".toRegex()) + } + } + + @Test + fun testCustomNamedScope() { + val customName = nextString() + val identity = CollectingLogger() + val loggerScope = object : LoggerScope by LoggerScope(customName, identity) {} + assertEquals(customName, loggerScope.scope) + assertEquals(identity, loggerScope.identity) + val level = Level.Debug + val message = nextString() + loggerScope.log(level, message) + val logged = identity.logged.first() + assertEquals(level, logged.level) + assertTrue { + logged.message.contains(".*(${loggerScope.scope}).*($message)".toRegex()) + } + } + + @Test + fun testSubScope() { + val scopeName = nextString() + val subScopeName = nextString() + val identity = CollectingLogger() + val level = Level.Verbose + val message = nextString() + val loggerScope = object : LoggerScope by LoggerScope(scopeName, identity) { + fun subScope() = scope(subScopeName) { + assertTrue("Expected that the '$scope' will contain '$scopeName' followed by '$subScopeName'") { + scope.contains(".*($scopeName).*($subScopeName)".toRegex()) + } + assertEquals(identity, this.identity) + log(level, message) + } + } + loggerScope.subScope() + } +} diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerTest.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerTest.kt new file mode 100644 index 00000000..b8e12c39 --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/LoggerTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2021-2023. 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.log + +import com.nice.cxonechat.log.Level.Debug +import com.nice.cxonechat.log.Level.Error +import com.nice.cxonechat.log.Level.Info +import com.nice.cxonechat.log.Level.Verbose +import com.nice.cxonechat.log.Level.Warning +import com.nice.cxonechat.log.fake.CollectingLogger +import com.nice.cxonechat.log.fake.LoggedMessage +import com.nice.cxonechat.log.tool.nextString +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +internal class LoggerTest { + + private lateinit var logger: CollectingLogger + + @Before + fun prepare() { + logger = CollectingLogger() + } + + @Test + fun verbose_withoutThrowable() { + val message = nextString() + logger.verbose(message) + logger.verifyLog(Verbose, message) + } + + @Test + fun verbose_withThrowable() { + val message = nextString() + val throwable = RuntimeException() + logger.verbose(message, throwable) + logger.verifyLog(Verbose, message, throwable) + } + + @Test + fun debug_withoutThrowable() { + val message = nextString() + logger.debug(message) + logger.verifyLog(Debug, message) + } + + @Test + fun debug_withThrowable() { + val message = nextString() + val throwable = RuntimeException() + logger.debug(message, throwable) + logger.verifyLog(Debug, message, throwable) + } + + @Test + fun info_withoutThrowable() { + val message = nextString() + logger.info(message) + logger.verifyLog(Info, message) + } + + @Test + fun info_withThrowable() { + val message = nextString() + val throwable = RuntimeException() + logger.info(message, throwable) + logger.verifyLog(Info, message, throwable) + } + + @Test + fun warning_withoutThrowable() { + val message = nextString() + logger.warning(message) + logger.verifyLog(Warning, message) + } + + @Test + fun warning_withThrowable() { + val message = nextString() + val throwable = RuntimeException() + logger.warning(message, throwable) + logger.verifyLog(Warning, message, throwable) + } + + @Test + fun error_withoutThrowable() { + val message = nextString() + logger.error(message) + logger.verifyLog(Error, message) + } + + @Test + fun error_withThrowable() { + val message = nextString() + val throwable = RuntimeException() + logger.error(message, throwable) + logger.verifyLog(Error, message, throwable) + } + + private fun CollectingLogger.verifyLog(level: Level, message: String, throwable: Throwable? = null) { + assertEquals(LoggedMessage(level, message, throwable), logged.first()) + } +} diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/ProxyLoggerTest.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/ProxyLoggerTest.kt new file mode 100644 index 00000000..542b9b0f --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/ProxyLoggerTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2021-2023. 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.log + +import com.nice.cxonechat.log.Level.Custom +import com.nice.cxonechat.log.Level.Verbose +import com.nice.cxonechat.log.fake.CollectingLogger +import com.nice.cxonechat.log.fake.LoggedMessage +import com.nice.cxonechat.log.tool.nextString +import org.junit.Test +import kotlin.test.assertEquals + +internal class ProxyLoggerTest { + @Test + fun testAdd() { + val logger = ProxyLogger() + assertEquals(0, logger.loggerCount) + val testLogger1 = CollectingLogger() + val testLogger2 = CollectingLogger() + logger.add(testLogger1, testLogger2) + assertEquals(2, logger.loggerCount) + assertEquals(listOf(testLogger1, testLogger2), logger.loggers) + } + + @Test + fun addAll() { + val logger = ProxyLogger() + assertEquals(0, logger.loggerCount) + val list = listOf(CollectingLogger(), CollectingLogger()) + logger.addAll(list) + assertEquals(2, logger.loggerCount) + assertEquals(list, logger.loggers) + } + + @Test + fun remove() { + val logger = ProxyLogger() + assertEquals(0, logger.loggerCount) + val testLogger = CollectingLogger() + val list = listOf(testLogger, CollectingLogger()) + logger.addAll(list) + logger.remove(testLogger) + assertEquals(1, logger.loggerCount) + assertEquals(list - testLogger, logger.loggers) + } + + @Test + fun clear() { + val logger = ProxyLogger() + assertEquals(0, logger.loggerCount) + val list = listOf(CollectingLogger(), CollectingLogger()) + logger.addAll(list) + logger.clear() + assertEquals(0, logger.loggerCount) + assertEquals(emptyList(), logger.loggers) + } + + @Test + fun addAndGetLoggerCount() { + val logger = ProxyLogger() + assertEquals(0, logger.loggerCount) + logger.add(CollectingLogger()) + assertEquals(1, logger.loggerCount) + } + + @Test + fun log() { + val logger = ProxyLogger() + assertEquals(0, logger.loggerCount) + val list = listOf(CollectingLogger(), CollectingLogger()) + logger.addAll(list) + val toLogList: List<LoggedMessage> = listOf( + LoggedMessage(Custom(1), nextString(), IllegalStateException(nextString())), + LoggedMessage(Verbose, nextString(), null) + ) + toLogList.forEach { toLog -> + logger.log( + level = toLog.level, + message = toLog.message, + throwable = toLog.throwable + ) + } + list.forEach { proxiedLogger -> + assertEquals(toLogList, proxiedLogger.logged) + } + } +} diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/fake/CollectingLogger.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/fake/CollectingLogger.kt new file mode 100644 index 00000000..86a5321f --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/fake/CollectingLogger.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021-2023. 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.log.fake + +import com.nice.cxonechat.log.Level +import com.nice.cxonechat.log.Logger + +internal class CollectingLogger : Logger { + val logged: MutableList<LoggedMessage> = mutableListOf() + override fun log(level: Level, message: String, throwable: Throwable?) { + logged.add(LoggedMessage(level, message, throwable)) + } +} diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/fake/LoggedMessage.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/fake/LoggedMessage.kt new file mode 100644 index 00000000..e1caa8a8 --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/fake/LoggedMessage.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021-2023. 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.log.fake + +import com.nice.cxonechat.log.Level + +internal data class LoggedMessage( + val level: Level, + val message: String, + val throwable: Throwable?, +) diff --git a/logger/src/test/kotlin/com/nice/cxonechat/log/tool/String.kt b/logger/src/test/kotlin/com/nice/cxonechat/log/tool/String.kt new file mode 100644 index 00000000..470e46b2 --- /dev/null +++ b/logger/src/test/kotlin/com/nice/cxonechat/log/tool/String.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021-2023. 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.log.tool + +private val Default = ('a'..'z') + ('A'..'Z') + ('0'..'9') + +internal fun nextString(length: Int = 10, pool: List<Char> = Default) = buildString { + repeat(length) { + append(pool.random()) + } +} diff --git a/readme.md b/readme.md index 12d9e00a..02b16c31 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,11 @@ # CXone Chat for Android -This repository consists out of three main modules, which are described in chapters below. +This repository consists out of three main modules and three tooling modules, +which are described in chapters below. ## CXOne Chat SDK -This is the only published module, it is released as an android multi-flavor library with maven artifact coordinates +This is the main published module, it is released as an android multi-flavor library with maven artifact coordinates `com.nice.cxone:chat-core`. ### Adding the dependency @@ -78,3 +79,24 @@ the intended target application. This is a mock application that tries to imitate e-store with its purchase flow, so we can also demonstrate an integration of the SDK analytics events like pageView or conversion. + +## Tooling modules: + +### logger + +The logger module is a minimalistic logging framework used by the chat-sdk-core without any platform-specific code. +It is distributed as a java library at the moment. + +The maven artifact coordinates are `com.nice.cxone:logger`. + +### logger-android + +The logger-android module provides the default android-specific implementation of the Logger. +Application can provide this instance to the SDK builder if it wishes the SDK to log to the Android +platform log output. + +The maven artifact coordinates are `com.nice.cxone:logger-android`. + +### utilities + +This is an internal module that attempts to resolve some of the issues reported by the strict mode. \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 7812214c..543c01ca 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,7 +1,15 @@ pluginManagement { repositories { gradlePluginPortal() - google() + google() { + content { + includeGroupByRegex "com.android.*" + includeGroupByRegex "androidx.*" + includeGroup "android.arch.lifecycle" + includeGroup "android.arch.core" + includeGroupByRegex "com.google.*" + } + } mavenCentral() } } @@ -13,16 +21,38 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - google() + google() { + content { + includeGroupByRegex "com.android.*" + includeGroupByRegex "androidx.*" + includeGroup "android.arch.lifecycle" + includeGroup "android.arch.core" + includeGroupByRegex "com.google.*" + } + } mavenCentral() - maven { url 'https://jitpack.io' } // Used only for com.github.jeziellago:compose-markdown + exclusiveContent { + forRepository { + //noinspection ForeignDelegate + maven { + url 'https://jitpack.io' + content { + includeModule("com.github.jeziellago", "Markwon") + includeModule("com.github.jeziellago", "compose-markdown") + } + } + } + filter { + includeGroup("com.github.jeziellago") + } + } } } rootProject.name = "cxone-chat-sdk" android { - compileSdk 33 + compileSdk 34 minSdk 23 execution { profiles { @@ -51,3 +81,6 @@ include ':chat-sdk-core' include ':chat-sdk-ui' include ':cxone-detekt-rules' include ':store' +include ':utilities' +include ':logger' +include ':logger-android' diff --git a/store/build.gradle b/store/build.gradle index 32671252..0974603e 100644 --- a/store/build.gradle +++ b/store/build.gradle @@ -1,12 +1,14 @@ plugins { id "android-application-conventions" id "android-ui-conventions" - id "kotlin-conventions" - id "kapt-conventions" - id "test-conventions" + id "android-kotlin-conventions" + id "ksp-conventions" + id "koin-conventions" + id "android-test-conventions" id "application-style-conventions" - id "com.google.dagger.hilt.android" id 'com.google.gms.google-services' + id "com.google.firebase.appdistribution" + id 'com.google.firebase.crashlytics' } def storeVersion = branchVersion(version, project) @@ -48,39 +50,41 @@ android { } } + sourceSets { + main { + assets.srcDirs += rootProject.file("shared") + java.srcDirs += new File(buildDir, "generated/ksp/main/kotlin") + } + } + + firebaseAppDistribution { + artifactType = "APK" + releaseNotesFile = "CHANGELOG.md" + } + + packagingOptions { + dex { + useLegacyPackaging false + } + } } -dependencies { - // Dagger Hilt - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' - implementation 'androidx.activity:activity-compose:1.7.2' - implementation("com.google.dagger:hilt-android:$daggerHiltVersion") - implementation 'androidx.compose.ui:ui' - implementation 'androidx.compose.ui:ui-graphics' - implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.material:material' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' - implementation 'com.google.android.material:material:1.9.0' - implementation "androidx.compose.runtime:runtime-livedata:1.5.0" - implementation "androidx.hilt:hilt-navigation-compose:1.0.0" - - // Preview support? - kapt("com.google.dagger:hilt-android-compiler:$daggerHiltVersion") - debugImplementation 'androidx.compose.ui:ui-test-manifest' - - def navVersion = "2.6.0" - implementation "androidx.navigation:navigation-fragment-ktx:$navVersion" - implementation "androidx.navigation:navigation-ui-ktx:$navVersion" +/* remove the superfluous files that happen to live in the shared assets directory */ +android.applicationVariants.all { variant -> + def task = variant.getMergeAssetsProvider().get() + + task.doLast { + delete(fileTree(dir: task.outputDir, includes: ['*.md', '*.scheme.json'])) + } +} +dependencies { def retrofitVersion = "2.9.0" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" // Initializer<T> - implementation "androidx.lifecycle:lifecycle-common:2.6.1" + implementation "androidx.lifecycle:lifecycle-common:2.7.0" implementation "androidx.startup:startup-runtime:1.1.1" // GoDaddy color picker @@ -88,22 +92,30 @@ dependencies { implementation "com.godaddy.android.colorpicker:compose-color-picker-android:$colorPickerVersion" // Firebase - implementation platform('com.google.firebase:firebase-bom:32.2.2') + implementation platform('com.google.firebase:firebase-bom:32.7.1') implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging-ktx' // Extended Emoji Support - def emojiVersion = "1.3.0" + def emojiVersion = "1.4.0" implementation "androidx.emoji2:emoji2:$emojiVersion" implementation "androidx.emoji2:emoji2-bundled:$emojiVersion" // AsyncImage - implementation("io.coil-kt:coil-compose:2.4.0") + implementation("io.coil-kt:coil-compose:2.5.0") // CXOne Chat SDK implementation project(":chat-sdk-core") implementation project(":chat-sdk-ui") + implementation project(':utilities') + implementation project(':logger-android') + + // Crashlytics + implementation("com.google.firebase:firebase-crashlytics-ktx") // LoginWithAmazon implementation fileTree(include: ['*.jar', '*.aar'], dir: rootProject.file("libs")) + + // Immutable annotations + implementation "com.google.code.findbugs:jsr305:3.0.2" } diff --git a/store/config/detekt/detekt-baseline.xml b/store/config/detekt/detekt-baseline.xml index c373eea4..fd46d387 100644 --- a/store/config/detekt/detekt-baseline.xml +++ b/store/config/detekt/detekt-baseline.xml @@ -1,4 +1,19 @@ <?xml version='1.0' encoding='UTF-8'?> +<!-- + ~ Copyright (c) 2021-2023. 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. + --> + <SmellBaseline> <ManuallySuppressedIssues/> <CurrentIssues/> diff --git a/store/lint-baseline.xml b/store/lint-baseline.xml index 8407352e..10ae643a 100644 --- a/store/lint-baseline.xml +++ b/store/lint-baseline.xml @@ -1,400 +1,26 @@ <?xml version="1.0" encoding="UTF-8"?> -<issues format="6" by="lint 8.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.1)" variant="all" version="8.1.1"> - - <issue - id="GradleDependency" - message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.6.1 is available: 2.6.2" - errorLine1=" implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="75" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of com.google.dagger:hilt-android than 2.45 is available: 2.48" - errorLine1=" implementation("com.google.dagger:hilt-android:$daggerHiltVersion")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="77" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.lifecycle:lifecycle-livedata-ktx than 2.6.1 is available: 2.6.2" - errorLine1=" implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="84" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.lifecycle:lifecycle-viewmodel-ktx than 2.6.1 is available: 2.6.2" - errorLine1=" implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="85" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.compose.runtime:runtime-livedata than 1.5.0 is available: 1.5.1" - errorLine1=" implementation "androidx.compose.runtime:runtime-livedata:1.5.0"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="87" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.navigation:navigation-fragment-ktx than 2.6.0 is available: 2.7.3" - errorLine1=" implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="95" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.navigation:navigation-ui-ktx than 2.6.0 is available: 2.7.3" - errorLine1=" implementation "androidx.navigation:navigation-ui-ktx:$navVersion"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="96" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.lifecycle:lifecycle-common than 2.6.1 is available: 2.6.2" - errorLine1=" implementation "androidx.lifecycle:lifecycle-common:2.6.1"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="103" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of com.google.firebase:firebase-bom than 32.2.2 is available: 32.3.1" - errorLine1=" implementation platform('com.google.firebase:firebase-bom:32.2.2')" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="111" - column="29"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.emoji2:emoji2 than 1.4.0-beta04 is available: 1.4.0" - errorLine1=" implementation "androidx.emoji2:emoji2:$emojiVersion"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="117" - column="20"/> - </issue> - - <issue - id="GradleDependency" - message="A newer version of androidx.emoji2:emoji2-bundled than 1.4.0-beta04 is available: 1.4.0" - errorLine1=" implementation "androidx.emoji2:emoji2-bundled:$emojiVersion"" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="build.gradle" - line="118" - column="20"/> - </issue> - - <issue - id="ComposableLambdaParameterNaming" - message="Composable lambda parameter should be named `content`" - errorLine1=" showCart: @Composable RowScope.() -> Unit," - errorLine2=" ~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/ui/ProductListScreen.kt" - line="143" - column="9"/> - </issue> - - <issue - id="ComposableLambdaParameterNaming" - message="Composable lambda parameter should be named `content`" - errorLine1=" showCart: @Composable RowScope.() -> Unit," - errorLine2=" ~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/ui/ProductScreen.kt" - line="119" - column="9"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.d(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.d("LoginDialog", "finish: $userName")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/ui/LoginDialog.kt" - line="88" - column="9"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.d(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.d(TAG, "Screen: uiState=${uiState.value}")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/StoreActivity.kt" - line="107" - column="9"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.d(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.d(TAG, "OverlayDialogs: uiState=$uiState")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/StoreActivity.kt" - line="150" - column="9"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.i(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.i(TAG, "LoginWithAmazon: ${p0?.message ?: getString(string.unknown_error)}")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/StoreActivity.kt" - line="200" - column="17"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.i(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.i(TAG, "LoginWithAmazon: ${p0?.description}")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/StoreActivity.kt" - line="204" - column="17"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.v(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.v(TAG, "currentPageView = $field -> $value")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/StoreViewModel.kt" - line="95" - column="13"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.d(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.d(TAG, "uiState: ${uiState.value} -> $state")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/StoreViewModel.kt" - line="136" - column="9"/> - </issue> - - <issue - id="LogConditional" - message="The log call Log.i(...) should be conditional: surround with `if (Log.isLoggable(...))` or `if (BuildConfig.DEBUG) { ... }`" - errorLine1=" Log.i(TAG, "loginWithAmazon.logout failure: ${error?.message}")" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/StoreViewModel.kt" - line="307" - column="21"/> - </issue> - - <issue - id="AutoboxingStateCreation" - message="Prefer `mutableIntStateOf` instead of `mutableStateOf`" - errorLine1=" var attempt by remember { mutableStateOf(0) }" - errorLine2=" ~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/ui/ProductListScreen.kt" - line="82" - column="39"/> - </issue> - - <issue - id="UnusedResources" - message="The resource `R.color.purple_200` appears to be unused" - errorLine1=" <color name="purple_200">#FFBB86FC</color>" - errorLine2=" ~~~~~~~~~~~~~~~~~"> - <location - file="src/main/res/values/colors.xml" - line="3" - column="12"/> - </issue> - - <issue - id="UnusedResources" - message="The resource `R.color.purple_500` appears to be unused" - errorLine1=" <color name="purple_500">#FF6200EE</color>" - errorLine2=" ~~~~~~~~~~~~~~~~~"> - <location - file="src/main/res/values/colors.xml" - line="4" - column="12"/> - </issue> - - <issue - id="UnusedResources" - message="The resource `R.color.purple_700` appears to be unused" - errorLine1=" <color name="purple_700">#FF3700B3</color>" - errorLine2=" ~~~~~~~~~~~~~~~~~"> - <location - file="src/main/res/values/colors.xml" - line="5" - column="12"/> - </issue> - - <issue - id="UnusedResources" - message="The resource `R.color.teal_200` appears to be unused" - errorLine1=" <color name="teal_200">#FF03DAC5</color>" - errorLine2=" ~~~~~~~~~~~~~~~"> - <location - file="src/main/res/values/colors.xml" - line="6" - column="12"/> - </issue> - - <issue - id="UnusedResources" - message="The resource `R.color.black` appears to be unused" - errorLine1=" <color name="black">#FF000000</color>" - errorLine2=" ~~~~~~~~~~~~"> - <location - file="src/main/res/values/colors.xml" - line="7" - column="12"/> - </issue> - - <issue - id="UnusedResources" - message="The resource `R.color.white` appears to be unused" - errorLine1=" <color name="white">#FFFFFFFF</color>" - errorLine2=" ~~~~~~~~~~~~"> - <location - file="src/main/res/values/colors.xml" - line="8" - column="12"/> - </issue> - - <issue - id="UnusedResources" - message="The resource `R.string.error_value_validation` appears to be unused" - errorLine1=" <string name="error_value_validation">Invalid Value</string>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/res/values/strings.xml" - line="24" - column="13"/> - </issue> - - <issue - id="SyntheticAccessor" - message="Access to `private` method `getItems` of class `Companion` requires synthetic accessor" - errorLine1=" get() = sequenceOf(items)" - errorLine2=" ~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/previewproviders/ProductsParameterProvider.kt" - line="13" - column="28"/> - </issue> +<issues format="6" by="lint 8.1.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.2)" variant="all" version="8.1.2"> <issue id="SyntheticAccessor" message="Access to `private` method `getViewModel` of class `StoreActivity` requires synthetic accessor" - errorLine1=" viewModel.setAuthorization(ChatAuthorization(codeVerifier, accessToken))" - errorLine2=" ~~~~~~~~~"> + errorLine1=" viewModel.chatSettingsHandler.setAuthorization(ChatAuthorization(codeVerifier, accessToken))" + errorLine2=" ~~~~~~~~~"> <location file="src/main/java/com/nice/cxonechat/sample/StoreActivity.kt" - line="195" - column="21"/> + line="177" + column="25"/> </issue> <issue id="SyntheticAccessor" - message="Access to `private` method `applyToChatSdk` of class `UISettingsRepositoryKt` requires synthetic accessor" - errorLine1=" uiSettingsModel.applyToChatSdk()" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message="Access to `private` method `onConnected` of class `StoreViewModel` requires synthetic accessor" + errorLine1=" ChatState.PREPARED -> onConnected()" + errorLine2=" ~~~~~~~~~~~"> <location - file="src/main/java/com/nice/cxonechat/sample/data/repository/UISettingsRepository.kt" - line="37" - column="9"/> - </issue> - - <issue - id="SyntheticAccessor" - message="Access to `private` method `applyToChatSdk` of class `UISettingsRepositoryKt` requires synthetic accessor" - errorLine1=" applyToChatSdk()" - errorLine2=" ~~~~~~~~~~~~~~"> - <location - file="src/main/java/com/nice/cxonechat/sample/data/repository/UISettingsRepository.kt" - line="47" - column="13"/> - </issue> - - <issue - id="TypographyDashes" - message="Replace "-" with an "en dash" character (–, &#8211;) ?" - errorLine1=" <string name="card_number_placeholder">0000-0000-0000-0000</string>" - errorLine2=" ~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/res/values/strings.xml" - line="60" - column="44"/> - </issue> - - <issue - id="TypographyEllipsis" - message="Replace "..." with ellipsis character (…, &#8230;) ?" - errorLine1=" <string name="loading">Loading...</string>" - errorLine2=" ~~~~~~~~~~"> - <location - file="src/main/res/values/strings.xml" - line="55" - column="28"/> - </issue> - - <issue - id="TypographyEllipsis" - message="Replace "..." with ellipsis character (…, &#8230;) ?" - errorLine1=" <string name="connecting">Connecting...</string>" - errorLine2=" ~~~~~~~~~~~~~"> - <location - file="src/main/res/values/strings.xml" - line="56" - column="31"/> + file="src/main/java/com/nice/cxonechat/sample/viewModel/StoreViewModel.kt" + line="214" + column="39"/> </issue> </issues> diff --git a/store/src/main/AndroidManifest.xml b/store/src/main/AndroidManifest.xml index a5740054..48d55b21 100644 --- a/store/src/main/AndroidManifest.xml +++ b/store/src/main/AndroidManifest.xml @@ -19,6 +19,13 @@ <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.HIDE_OVERLAY_WINDOWS" /> + + <permission + android:name="com.nice.cxonechat.sample.permission.CHAT_VIEW" + android:label="@string/label_chat_permission" + android:description="@string/desc_chat_permission" + android:protectionLevel="signature|internal" /> <application android:name=".StoreApplication" @@ -30,6 +37,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/StoreFrontTheme" + android:useEmbeddedDex="true" + android:allowAudioPlaybackCapture="false" tools:targetApi="31"> <provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" @@ -72,13 +81,12 @@ <activity android:name="com.nice.cxonechat.ui.ChatActivity" android:exported="true" + android:launchMode="singleTop" + android:permission="com.nice.cxonechat.sample.permission.CHAT_VIEW" tools:replace="android:exported"> <intent-filter> <action android:name="android.intent.action.VIEW" /> - <category android:name="android.intent.category.DEFAULT" /> - <category android:name="android.intent.category.BROWSABLE" /> - <data android:scheme="com.nice.cxonechat.sample" /> </intent-filter> </activity> diff --git a/store/src/main/java/com/nice/cxonechat/sample/ChatInitializer.kt b/store/src/main/java/com/nice/cxonechat/sample/ChatInitializer.kt index 730c6435..fa2558ca 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ChatInitializer.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ChatInitializer.kt @@ -20,13 +20,18 @@ import androidx.startup.Initializer import com.google.firebase.ktx.Firebase import com.google.firebase.messaging.ktx.messaging import com.nice.cxonechat.ChatInstanceProvider +import com.nice.cxonechat.log.LoggerAndroid +import com.nice.cxonechat.log.ProxyLogger import com.nice.cxonechat.sample.data.repository.ChatSettingsRepository +import com.nice.cxonechat.sample.data.repository.UISettingsRepository +import com.nice.cxonechat.sample.utilities.logging.FirebaseLogger /** Automatic initialization of ChatInstanceProvider. */ -class ChatInitializer: Initializer<ChatInstanceProvider> { +class ChatInitializer : Initializer<ChatInstanceProvider> { override fun create(context: Context): ChatInstanceProvider { /* set up the chat instance provider */ val settings = ChatSettingsRepository(context).load() + UISettingsRepository(context).load() return ChatInstanceProvider.create( configuration = settings?.sdkConfiguration?.asSocketFactoryConfiguration, authorization = null, @@ -34,7 +39,11 @@ class ChatInitializer: Initializer<ChatInstanceProvider> { developmentMode = true, deviceTokenProvider = { setToken -> Firebase.messaging.token.addOnSuccessListener(setToken) - } + }, + logger = ProxyLogger( + FirebaseLogger(), + LoggerAndroid("CXoneChat") + ) ) } diff --git a/store/src/main/java/com/nice/cxonechat/sample/StoreActivity.kt b/store/src/main/java/com/nice/cxonechat/sample/StoreActivity.kt index e7f03157..5e7e37d7 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/StoreActivity.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/StoreActivity.kt @@ -16,20 +16,19 @@ package com.nice.cxonechat.sample import android.content.Context +import android.net.Uri +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly -import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import com.amazon.identity.auth.device.AuthError @@ -40,32 +39,30 @@ import com.amazon.identity.auth.device.api.authorization.AuthorizeRequest import com.amazon.identity.auth.device.api.authorization.AuthorizeResult import com.amazon.identity.auth.device.api.authorization.ProfileScope import com.amazon.identity.auth.device.api.workflow.RequestContext -import com.nice.cxonechat.UserName +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.debug +import com.nice.cxonechat.log.error +import com.nice.cxonechat.log.info +import com.nice.cxonechat.log.scope import com.nice.cxonechat.sample.R.string -import com.nice.cxonechat.sample.UiState.CONFIGURATION -import com.nice.cxonechat.sample.UiState.CONNECTING -import com.nice.cxonechat.sample.UiState.INITIAL -import com.nice.cxonechat.sample.UiState.LOGIN -import com.nice.cxonechat.sample.UiState.OAUTH -import com.nice.cxonechat.sample.UiState.UI_SETTINGS import com.nice.cxonechat.sample.data.models.ChatAuthorization -import com.nice.cxonechat.sample.data.models.SdkConfiguration -import com.nice.cxonechat.sample.data.models.SdkConfigurations -import com.nice.cxonechat.sample.data.repository.UISettings -import com.nice.cxonechat.sample.extensions.Ignored -import com.nice.cxonechat.sample.ui.BusySpinner import com.nice.cxonechat.sample.ui.CartScreen import com.nice.cxonechat.sample.ui.ConfirmationScreen -import com.nice.cxonechat.sample.ui.LoginDialog import com.nice.cxonechat.sample.ui.PaymentScreen import com.nice.cxonechat.sample.ui.ProductListScreen import com.nice.cxonechat.sample.ui.ProductScreen import com.nice.cxonechat.sample.ui.Screen -import com.nice.cxonechat.sample.ui.SdkConfigurationDialog import com.nice.cxonechat.sample.ui.theme.AppTheme -import com.nice.cxonechat.sample.ui.uisettings.UISettingsDialog import com.nice.cxonechat.sample.utilities.PKCE -import dagger.hilt.android.AndroidEntryPoint +import com.nice.cxonechat.sample.viewModel.StoreViewModel +import com.nice.cxonechat.sample.viewModel.UiState +import com.nice.cxonechat.sample.viewModel.UiState.UiStateContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.File import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicReference @@ -73,9 +70,10 @@ import java.util.concurrent.atomic.AtomicReference /** * Store activity hosting Compose Navigation-based sample host application and integration. */ -@AndroidEntryPoint -class StoreActivity : ComponentActivity() { - private val viewModel by viewModels<StoreViewModel>() +class StoreActivity : ComponentActivity(), UiStateContext { + private val storeViewModel: StoreViewModel by viewModel() + private val pageViewHandler get() = storeViewModel.analyticsHandler + private val requestContext by lazy { RequestContext.create(this as Context) } @@ -83,21 +81,23 @@ class StoreActivity : ComponentActivity() { private val pickMedia = registerForActivityResult(PickVisualMedia()) { pickedUri -> // Callback is invoked after the user selects a media item or closes the // photo picker. - val uriString = runCatching { - val uri = checkNotNull(pickedUri) - val localCopy = File(filesDir, "logo") - checkNotNull(contentResolver.openInputStream(uri)) - .use { inputStream -> localCopy.outputStream().use(inputStream::copyTo) } - localCopy.toUri().toString() - }.getOrNull() - onPickImageCallback.getAndSet(null)?.get()?.invoke(uriString) + lifecycleScope.launch { + val uriString = copyUriInputToLocalFile(pickedUri) + onPickImageCallback.getAndSet(null)?.get()?.invoke(uriString) + } } private val onPickImageCallback = AtomicReference<WeakReference<(String?) -> Unit>?>(null) + private val logger by lazy { LoggerScope(TAG, get()) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (VERSION.SDK_INT >= VERSION_CODES.S) { + window.setHideOverlayWindows(true) + } + setContent { AppTheme { Screen() @@ -108,27 +108,22 @@ class StoreActivity : ComponentActivity() { override fun onResume() { super.onResume() requestContext.onResume() - viewModel.startChat() + + // Let the viewModels know activity has been resumed to properly track page view. + storeViewModel.onResume() + pageViewHandler.onResume() + } + + override fun onPause() { + // let the viewModels know activity has been paused to properly track page view. + pageViewHandler.onPause() + super.onPause() } @Composable private fun Screen() { - val uiState = viewModel.uiState.collectAsState() - val settings = viewModel.chatSettingsRepository.settings.collectAsState() - val configuration = remember { derivedStateOf { settings.value?.sdkConfiguration } } - val userName = remember { derivedStateOf { settings.value?.userName } } - val configurations = viewModel.sdkConfigurationListRepository.configurationList.collectAsState() - - Log.d(TAG, "Screen: uiState=${uiState.value}") - NavScreen() - PresentDialogs( - uiState.value, - configuration.value, - configurations.value, - userName.value, - viewModel::cancelConnect - ) + PresentDialogs(storeViewModel.uiState.collectAsState().value) } /** @@ -146,7 +141,7 @@ class StoreActivity : ComponentActivity() { startDestination = ProductListScreen.defaultRoute, ) { screens.forEach { - it.navigation(this, navHostController, viewModel) + it.navigation(this, navHostController, storeViewModel) } } } @@ -155,70 +150,22 @@ class StoreActivity : ComponentActivity() { * Overlay any required dialogs. */ @Composable - private fun PresentDialogs( - uiState: UiState, - configuration: SdkConfiguration?, - configurations: SdkConfigurations, - userName: UserName?, - cancelConnect: () -> Unit - ) { - Log.d(TAG, "OverlayDialogs: uiState=$uiState") - - when (uiState) { - INITIAL -> BusySpinner(message = stringResource(string.loading)) - - CONFIGURATION -> SdkConfigurationDialog( - configuration, - configurations, - { viewModel.cancelConfigurationDialog() }, - viewModel::setConfiguration - ) - - CONNECTING -> BusySpinner( - message = stringResource(string.connecting), - onCancel = cancelConnect - ) - - LOGIN -> LoginDialog(userName, viewModel::setUserName) - - OAUTH -> loginWithAmazon() - - UI_SETTINGS -> UISettingsDialog( - value = UISettings.collectAsState().value, - onDismiss = viewModel::cancelUiSettings, - pickImage = ::pickImage, - ) { - viewModel.uiSettingsRepository.save(it) - } + private fun PresentDialogs(uiState: UiState) = logger.scope("PresentDialogs") { + debug("OverlayDialogs: uiState=$uiState") - else -> Ignored - } + uiState.Content(this@StoreActivity) } - private fun pickImage(onPickImage: (String?) -> Unit) { + override fun pickImage(onPickImage: (String?) -> Unit) { onPickImageCallback.set(WeakReference(onPickImage)) // Launch the photo picker and let the user choose only images. pickMedia.launch(PickVisualMediaRequest(ImageOnly)) } - private fun loginWithAmazon() { + override fun loginWithAmazon() = logger.scope("loginWithAmazon") { val (codeVerifier, codeChallenge) = PKCE.generateCodeVerifier() - requestContext.registerListener(object : AuthorizeListener() { - override fun onSuccess(result: AuthorizeResult?) { - result?.accessToken?.let { accessToken -> - viewModel.setAuthorization(ChatAuthorization(codeVerifier, accessToken)) - } ?: Log.e(TAG, "loginWithAmazon success with no result") - } - - override fun onError(p0: AuthError?) { - Log.i(TAG, "LoginWithAmazon: ${p0?.message ?: getString(string.unknown_error)}") - } - - override fun onCancel(p0: AuthCancellation?) { - Log.i(TAG, "LoginWithAmazon: ${p0?.description}") - } - }) + requestContext.registerListener(LoggingAuthorizeListener(codeVerifier, this)) AuthorizationManager.authorize( AuthorizeRequest.Builder(requestContext) @@ -229,15 +176,46 @@ class StoreActivity : ComponentActivity() { ) } - companion object { + private inner class LoggingAuthorizeListener( + private val codeVerifier: String, + logger: Logger, + ) : AuthorizeListener(), LoggerScope by LoggerScope<AuthorizeListener>(logger) { + override fun onSuccess(result: AuthorizeResult?) = scope("onSuccess") { + result?.accessToken?.let { accessToken -> + storeViewModel.chatSettingsHandler.setAuthorization(ChatAuthorization(codeVerifier, accessToken)) + } ?: error("loginWithAmazon success with no result") + } + + override fun onError(authError: AuthError?) = scope("onError") { + info("LoginWithAmazon: ${authError?.message ?: getString(string.unknown_error)}") + } + + override fun onCancel(authCancellation: AuthCancellation?) = scope("onCancel") { + info("LoginWithAmazon: ${authCancellation?.description}") + } + } + + private companion object { private const val TAG = "StoreActivity" - private val screens: List<Screen> = listOf( + val screens: List<Screen> = listOf( ProductListScreen, ProductScreen, CartScreen, PaymentScreen, ConfirmationScreen, ) + + suspend fun Context.copyUriInputToLocalFile( + pickedUri: Uri? + ): String? = withContext(Dispatchers.IO) { + runCatching { + val uri = checkNotNull(pickedUri) + val localCopy = File(filesDir, "logo") + checkNotNull(contentResolver.openInputStream(uri)) + .use { inputStream -> localCopy.outputStream().use(inputStream::copyTo) } + localCopy.toUri().toString() + }.getOrNull() + } } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/StoreApplication.kt b/store/src/main/java/com/nice/cxonechat/sample/StoreApplication.kt index b50657d4..d723dc65 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/StoreApplication.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/StoreApplication.kt @@ -16,24 +16,44 @@ package com.nice.cxonechat.sample import android.app.Application +import android.os.Build +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy import androidx.emoji2.bundled.BundledEmojiCompatConfig import androidx.emoji2.text.EmojiCompat +import coil.ImageLoader +import coil.ImageLoaderFactory import com.google.firebase.FirebaseApp -import com.nice.cxonechat.sample.data.repository.ChatSettingsRepository -import dagger.hilt.android.HiltAndroidApp -import javax.inject.Inject +import com.nice.cxonechat.log.LoggerAndroid +import com.nice.cxonechat.log.ProxyLogger +import com.nice.cxonechat.sample.modules.StoreModule +import com.nice.cxonechat.sample.utilities.logging.FirebaseLogger +import com.nice.cxonechat.ui.UiModule.Companion.chatUiModule +import com.nice.cxonechat.utilities.TaggingSocketFactory +import okhttp3.OkHttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koin.ksp.generated.module /** * Host application, initializes customized Emoji and Firebase. */ -@HiltAndroidApp -class StoreApplication : Application() { - @Inject - internal lateinit var chatSettingsRepository: ChatSettingsRepository - +class StoreApplication : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() + startKoin { + androidContext(applicationContext) + chatUiModule( + ProxyLogger( + FirebaseLogger(), + LoggerAndroid("CXoneChatUi") + ) + ) + modules(StoreModule().module) + } + /* SampleApp is using a bundled version of an emoji support library, for better support of the latest emojis on clean emulator instances. @@ -48,5 +68,33 @@ class StoreApplication : Application() { /* set up Firebase */ FirebaseApp.initializeApp(applicationContext) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + StrictModePolicy.apply() + } else { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + ) + + StrictMode.setVmPolicy( + VmPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + ) + } + } + + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .okHttpClient( + OkHttpClient.Builder() + .socketFactory(TaggingSocketFactory) + .build() + ) + .build() } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/StoreViewModel.kt b/store/src/main/java/com/nice/cxonechat/sample/StoreViewModel.kt deleted file mode 100644 index 7e0aae6a..00000000 --- a/store/src/main/java/com/nice/cxonechat/sample/StoreViewModel.kt +++ /dev/null @@ -1,435 +0,0 @@ -/* - * Copyright (c) 2021-2023. 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.sample - -import android.app.Application -import android.content.Context -import android.util.Log -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.Lifecycle.Event.ON_PAUSE -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import com.amazon.identity.auth.device.AuthError -import com.amazon.identity.auth.device.api.Listener -import com.amazon.identity.auth.device.api.authorization.AuthorizationManager -import com.google.firebase.ktx.Firebase -import com.google.firebase.messaging.ktx.messaging -import com.nice.cxonechat.Authorization -import com.nice.cxonechat.ChatEventHandlerActions.conversion -import com.nice.cxonechat.ChatEventHandlerActions.pageView -import com.nice.cxonechat.ChatEventHandlerActions.pageViewEnded -import com.nice.cxonechat.ChatInstanceProvider -import com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider -import com.nice.cxonechat.ChatState -import com.nice.cxonechat.UserName -import com.nice.cxonechat.sample.UiState.CONFIGURATION -import com.nice.cxonechat.sample.UiState.CONNECTED -import com.nice.cxonechat.sample.UiState.CONNECTING -import com.nice.cxonechat.sample.UiState.LOGIN -import com.nice.cxonechat.sample.UiState.OAUTH -import com.nice.cxonechat.sample.UiState.UI_SETTINGS -import com.nice.cxonechat.sample.data.models.ChatSettings -import com.nice.cxonechat.sample.data.models.SdkConfiguration -import com.nice.cxonechat.sample.data.models.toChatAuthorization -import com.nice.cxonechat.sample.data.models.toChatUserName -import com.nice.cxonechat.sample.data.repository.ChatSettingsRepository -import com.nice.cxonechat.sample.data.repository.SdkConfigurationListRepository -import com.nice.cxonechat.sample.data.repository.StoreRepository -import com.nice.cxonechat.sample.data.repository.UISettingsRepository -import com.nice.cxonechat.sample.extensions.Ignored -import com.nice.cxonechat.sample.extensions.OnLifecycleEvent -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.util.Date -import javax.inject.Inject - -/** Current state of the UI. */ -enum class UiState { - /** Nothing has been done yet. */ - INITIAL, - - /** Requesting Configuration details from the user. */ - CONFIGURATION, - - /** Attempting to connect. */ - CONNECTING, - - /** Performing OAuth authentication with the user. */ - OAUTH, - - /** Performing simple authentication with the user. */ - LOGIN, - - /** Displaying the UI Settings dialog. */ - UI_SETTINGS, - - /** connected to the server. */ - CONNECTED -} - -/** - * ViewModel for the StoreActivity. - */ -@Suppress("TooManyFunctions") -@HiltViewModel -class StoreViewModel @Inject constructor( - application: Application, - /** store repository containing cart and product information. */ - val storeRepository: StoreRepository, - /** sdk configuration repository containing list of predefined SDK configurations. */ - val sdkConfigurationListRepository: SdkConfigurationListRepository, - /** chat configuration and settings repository containing the current configuration. */ - val chatSettingsRepository: ChatSettingsRepository, - /** UI settings repository saving and managing UI configuration. */ - val uiSettingsRepository: UISettingsRepository, -) : AndroidViewModel(application), ChatInstanceProvider.Listener { - /** chat repository containing current chat. */ - val chatProvider = ChatInstanceProvider.get() - - @Immutable - private data class PageView(val title: String, val url: String, val date: Date = Date()) - - private var currentPageView: PageView? = null - set(value) { - Log.v(TAG, "currentPageView = $field -> $value") - field = value - } - - private val uiStateStore = MutableStateFlow(UiState.INITIAL) - - private val context - get() = getApplication() as Context - - /** Current UI State. */ - @Stable - val uiState = uiStateStore.asStateFlow() - - init { - chatSettingsRepository.load() - uiSettingsRepository.load() - sdkConfigurationListRepository.load() - } - - /** - * Update UI State. - * - * @param expect expected UI state. State will only be updated if the current state is [expect]. - * @param state new UI State. - */ - private fun setUiState(expect: UiState, state: UiState) { - if (state == uiState.value) { - Log.d(TAG, "uiState: ${uiState.value} -> $state ignored") - } else if (uiStateStore.compareAndSet(expect, state)) { - Log.d(TAG, "uiState: $expect -> $state") - } else { - Log.d(TAG, "setUiState: expected state change: $expect -> $state: actual=${uiState.value}") - } - } - - /** - * Update UI State. - * - * @param state new UI State. - */ - private fun setUiState(state: UiState) { - Log.d(TAG, "uiState: ${uiState.value} -> $state") - uiStateStore.value = state - } - - /** - * Start up chat as required/possible. - */ - fun startChat() { - chatProvider.addListener(this) - - if (chatSettingsRepository.settings.value == null) { - setUiState(CONFIGURATION) - } - else if (!setOf(CONNECTED, CONNECTING).contains(uiState.value)) { - connect() - } - } - - /** - * Stop the chat as required. - */ - fun stopChat() { - chatProvider.removeListener(this) - chatProvider.stop() - } - - /** - * present SDK configuration alert. - */ - fun presentConfigurationDialog() { - setUiState(CONFIGURATION) - } - - /** - * Cancel the sdk configuration dialog. - */ - fun cancelConfigurationDialog() { - setUiState(CONFIGURATION, CONNECTED) - } - - /** Display the UI Settings dialog. */ - fun presentUiSettings() { - setUiState(UI_SETTINGS) - } - - /** Cancel the UI Settings dialog. */ - fun cancelUiSettings() { - setUiState(UI_SETTINGS, CONNECTED) - } - - /** - * Set the sdk configuration to use for future attempts, if the configuration - * has changed, a new connection will be established. - * - * @param sdkConfiguration new configuration to use. - */ - fun setConfiguration(sdkConfiguration: SdkConfiguration) { - val settings = chatSettingsRepository.settings.value?.copy( - sdkConfiguration = sdkConfiguration, - authorization = null, - userName = null, - ) ?: ChatSettings(sdkConfiguration, null, null) - - apply(settings) - } - - /** - * Set the user name for future connections. - * - * If the name changes, a new connection will be established - * - * @param userName New user name to use. - */ - fun setUserName(userName: UserName) { - apply( - chatSettingsRepository.settings.value?.copy( - userName = userName.toChatUserName, - ) - ) - } - - /** - * Set the authorization to use for future connections. - * - * A new connection will be established - * - * @param authorization new authorization to use. - */ - fun setAuthorization(authorization: Authorization) { - apply( - chatSettingsRepository.settings.value?.copy( - authorization = authorization.toChatAuthorization, - ) - ) - } - - /** - * Apply save a set of settings changes and apply them to the chatProvider. - * - * @param settings ChatSettings to apply. - */ - private fun apply(settings: ChatSettings?) { - settings?.let(chatSettingsRepository::use) ?: chatSettingsRepository.clear() - - chatProvider.signOut() - - chatProvider.configure(context) { - configuration = settings?.sdkConfiguration?.asSocketFactoryConfiguration - userName = settings?.userName - authorization = settings?.authorization - deviceTokenProvider = DeviceTokenProvider { setToken -> - Firebase - .messaging - .token - .addOnSuccessListener(setToken) - .addOnFailureListener { - Log.e(TAG, "Firebase.messaging.token failed: $it") - } - } - } - } - - /** - * A connection has been established, check it's validity based on: - * * if authentication is enabled, make sure we have appropriate OAuth details - * * otherwise make sure we have a valid user name. - */ - private fun onConnected() { - val settings = chatSettingsRepository.settings.value - - when (chatProvider.chat?.configuration?.isAuthorizationEnabled) { - null -> { - Log.e(TAG, "No chat when in CONNECTED state.") - null - } - - true -> if (settings?.authorization != null) { - CONNECTED - } else { - OAUTH - } - - false -> if (settings?.userName != null) { - CONNECTED - } else { - LOGIN - } - }?.also { state -> - if (!(setOf(UI_SETTINGS, CONFIGURATION).contains(uiState.value) && state == CONNECTED)) { - setUiState(state) - } - if (state == CONNECTED) { - // If we're connected for real now, send a pending page view - sendPageView() - } - } - } - - /** - * Clear any saved user authentication credentials from the ChatProvider - * and saved storage. - */ - private fun clearAuthentication() { - AuthorizationManager.signOut( - context, - object : Listener<Void, AuthError> { - override fun onSuccess(ignore: Void?) { - Log.i(TAG, "loginWithAmazon.logout success") - } - - override fun onError(error: AuthError?) { - Log.i(TAG, "loginWithAmazon.logout failure: ${error?.message}") - } - } - ) - - apply( - chatSettingsRepository.settings.value?.copy(authorization = null, userName = null) - ) - } - - /** - * Attempt to connect to the chat server with the info we have available to date. - * - * The ui state will be advanced to CONFIGURATION, LOGIN, or OAUTH depending on results. - * In a "normal" environment, this is all unnecessary as the host application will - * have a predefined configuration and should be prepared in advance to perform - * OAuth authentication or collect user information as needed. - */ - fun connect() { - chatProvider.start(context) - } - - /** - * Cancel a pending [ChatProvider.start] request. - */ - fun cancelConnect() { - chatProvider.cancelStart() - } - - /** - * Log out, clearing out all configuration-dependent information. - * - * Clears and Resets: - * * Chat connection - * * UI Settings - * * Store cart and user information - */ - fun logout() { - clearAuthentication() - - // This needs to be *after* all the settings are cleared out or we - // immediately reconnect using the same information. - chatProvider.signOut() - } - - override fun onCleared() { - Log.v(TAG, "ViewModel cleared") - super.onCleared() - } - - /** - * Start a LaunchedEffect to send the page view event when possible and - * necessary. - * - * @param title Title of page to send. - * @param url URL of page to send. - */ - @Composable - fun SendPageView(title: String, url: String) { - currentPageView = PageView(title, url) - - OnLifecycleEvent { _, event -> - when (event) { - ON_RESUME -> sendPageView() - ON_PAUSE -> sendPageViewEnded() - else -> Ignored - } - } - } - - private fun sendPageView() { - currentPageView?.run { - chatProvider.chat?.events()?.pageView(title, url, date) - } - } - - private fun sendPageViewEnded() { - currentPageView?.run { - chatProvider.chat?.events()?.pageViewEnded(title, url, Date()) - } - } - - /** - * Send a conversion event to the analytics service. - * - * @param type application-specific "type" of conversion. - * @param amount dollar amount of conversion. - * @param date date of conversion, defaults to now. - */ - fun sendConversion(type: String, amount: Double, date: Date = Date()) { - chatProvider.chat?.events()?.conversion(type, amount, date) - } - - // - // ChatInstanceProvider.Listener Implementation - // - - /** - * Send a pending page view if chat is just now established. - */ - override fun onChatStateChanged(chatState: ChatState) { - // If the chat has now connected, see if we need to send authorization - when (chatState) { - ChatState.INITIAL -> setUiState(CONFIGURATION) - ChatState.CONNECTING -> setUiState(CONNECTING) - ChatState.CONNECTED -> onConnected() - else -> Ignored - } - } - - companion object { - private const val TAG = "StoreViewModel" - } -} diff --git a/store/src/main/java/com/nice/cxonechat/sample/StrictModePolicy.kt b/store/src/main/java/com/nice/cxonechat/sample/StrictModePolicy.kt new file mode 100644 index 00000000..e188e382 --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/StrictModePolicy.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2021-2023. 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.sample + +import android.os.Build +import android.os.PatternMatcher +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy +import android.os.strictmode.DiskReadViolation +import android.os.strictmode.LeakedClosableViolation +import androidx.annotation.RequiresApi +import com.nice.cxonechat.sample.data.repository.ChatSettingsRepository +import com.nice.cxonechat.sample.data.repository.UISettingsRepository +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Actions +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Actions.log +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Actions.terminate +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Predicates.allOf +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Predicates.any +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Predicates.classNamed +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Predicates.violation +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.allow +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Rule +import java.util.concurrent.Executors + +@RequiresApi(Build.VERSION_CODES.P) +internal object StrictModePolicy { + private val threadPolicy = RuleBasedPenalty( + // DE-66838 + allow( + allOf( + violation(DiskReadViolation::class), + classNamed(UISettingsRepository::class.qualifiedName!!, "load"), + ), + ), + // DE-66839 + allow( + allOf( + violation(DiskReadViolation::class), + classNamed(ChatSettingsRepository::class.qualifiedName!!, "load"), + ) + ), + // Samsung Galaxy Note 10 - Android 9 + allow( + allOf( + violation(DiskReadViolation::class), + classNamed("android.graphics.Typeface", "SetAppTypeFace") + ) + ), + // Samsung devices are causing DiskReadViolation when permission is requested + allow( + allOf( + violation(DiskReadViolation::class), + classNamed(PatternMatcher("""com.samsung.android.knox.""", PatternMatcher.PATTERN_PREFIX)) + ) + ), + // Samsung A20 - Android 10 - first app run + allow( + allOf( + violation(DiskReadViolation::class), + classNamed( + PatternMatcher( + """com.android.server.am.freecess.FreecessController""", + PatternMatcher.PATTERN_PREFIX, + ), + ) + ) + ), + // Samsung S22 - Android 13 + allow( + allOf( + violation(DiskReadViolation::class), + classNamed(PatternMatcher("""android.app.IdsController""", PatternMatcher.PATTERN_PREFIX)) + ) + ), + allow( + allOf( + violation(DiskReadViolation::class), + classNamed("android.app.SharedPreferencesImpl\$EditorImpl", "isSpeg") + ) + ), + // Samsung S8 - Android 9 - Coil - ImageLoader.Builder.build + allow( + allOf( + violation(DiskReadViolation::class), + classNamed("com.samsung.android.feature.SemCscFeature", "isUseOdmProduct") + ) + ), + // LGE - V40 ThinQ - Reported via Crashlytics + allow( + allOf( + violation(DiskReadViolation::class), + classNamed("android.content.res.Resources", "startParallelLoading") + ) + ), + // Samsung and possibly other Qualcomm devices make bad disk reads passing through here. + allow( + allOf( + violation(DiskReadViolation::class), + classNamed("android.util.BoostFramework", "<init>") + ) + ), + // Emulator - Android 12 - Reported via Crashlytics + allow( + allOf( + violation(DiskReadViolation::class), + classNamed("com.android.server.wm.WindowManagerService", "getTaskSnapshot") + ) + ), + // Perfecto instrumentation rewrites app and adds it's own methods + allow( + allOf( + violation(DiskReadViolation::class), + classNamed(StoreActivity::class.qualifiedName!!, "onCreatePerfectoMobile") + ) + ), + // Default action is to log and crash + Rule(any(), Actions.allOf(log("ThreadPolicy"), terminate())) + ) + + private val vmPolicy = RuleBasedPenalty( + // Platform bug https://github.com/aosp-mirror/platform_frameworks_base/commit/e7ae30f76788bcec4457c4e0b0c9cbff2cf892f3 + allow( + allOf( + violation(LeakedClosableViolation::class), + classNamed("sun.nio.fs.UnixSecureDirectoryStream", "finalize"), + { _ -> Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE } + ) + ), + + // DE-66827 + Rule( + violation(LeakedClosableViolation::class), + log("VMPolicy") + ), + // Crashlytics + allow( + classNamed("com.google.android.datatransport.runtime.SafeLoggingExecutor\$SafeLoggingRunnable") + ), + allow( + classNamed( + PatternMatcher("""com.google.firebase.""", PatternMatcher.PATTERN_PREFIX) + ) + ), + // Default action is to log and crash + Rule(any(), Actions.allOf(log("VMPolicy"), terminate())) + ) + + private val executor = Executors.newSingleThreadScheduledExecutor() + + fun apply() { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .detectAll() + .penaltyListener(executor, threadPolicy::perform) + .build() + ) + + StrictMode.setVmPolicy( + VmPolicy.Builder() + .detectAll() + .penaltyListener(executor, vmPolicy::perform) + .build() + ) + } +} 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 395ea1fc..3d1576a9 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 @@ -21,13 +21,14 @@ 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 /** * UI Settings as saved to file. * * @param lightModeColors Colors to be used in light mode. * @param darkModeColors Colors to be used in dark mode. - * @param logo Logo image which should be used for chat branding. + * @param storedLogo Logo image which should be used for chat branding. */ @Immutable data class UISettingsModel( @@ -36,8 +37,13 @@ data class UISettingsModel( @SerializedName("darkModeColors") val darkModeColors: Colors = Colors(Dark), @SerializedName("logo") - val logo: String? = null, + private val storedLogo: String? = null, ) { + + /** Either stored logo, or default image. */ + val logo: Any + get() = storedLogo ?: Images.logo + /** * A set of custom colors to be applied during either day or night mode. * diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/AssetRepository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/AssetRepository.kt index 9f90de48..2f72efab 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/AssetRepository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/AssetRepository.kt @@ -24,34 +24,30 @@ import kotlin.reflect.KClass * A read-only repository to read a typed-object from a named Android asset. * * @param Type Type of asset to read. - * @property name Name of asset to read. + * @param name Name of asset to read. * @param type Class of asset to read. */ -open class AssetRepository<Type: Any>( +open class AssetRepository<Type : Any>( private val name: String, - type: KClass<Type> + type: KClass<Type>, ) : Repository<Type>(type) { + override fun doStore(string: String, context: Context) { - throw RepositoryError(TAG, "An attempt was made to write to asset: $name") + throw RepositoryError("An attempt was made to write to asset: $name") } - override fun doLoad(context: Context): String? { - return try { - return context.assets.open(name).use { + override fun doLoad(context: Context): String? = + try { + context.assets.open(name).use { doLoad(it) } } catch (_: FileNotFoundException) { null } catch (exc: IOException) { - throw RepositoryError(TAG, "Error loading settings: $name", exc) + throw RepositoryError("Error loading settings: $name", exc) } - } override fun doClear(context: Context) { - throw RepositoryError(TAG, "An attempt was made to clear asset: $name") - } - - companion object { - private const val TAG = "AssetRepository" + throw RepositoryError("An attempt was made to clear asset: $name") } } 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 0065e176..fe3d66d1 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 @@ -17,21 +17,21 @@ package com.nice.cxonechat.sample.data.repository import android.content.Context import com.nice.cxonechat.sample.data.models.ChatSettings -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton +import org.koin.core.annotation.Single /** * Repository responsible saving, loading, and tracking chat related settings. * * @param context Application context for file access. */ -@Singleton -class ChatSettingsRepository @Inject constructor(@ApplicationContext val context: Context) : FileRepository<ChatSettings>( - "settings.json", - ChatSettings::class +@Single +class ChatSettingsRepository( + val context: Context, +) : FileRepository<ChatSettings>( + fileName = "settings.json", + type = ChatSettings::class, ) { private val mutableSettings = MutableStateFlow<ChatSettings?>(null) diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/FileRepository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/FileRepository.kt index 259475a9..807b9903 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/FileRepository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/FileRepository.kt @@ -16,6 +16,9 @@ package com.nice.cxonechat.sample.data.repository import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.io.FileNotFoundException import java.io.IOException import kotlin.reflect.KClass @@ -24,41 +27,38 @@ import kotlin.reflect.KClass * A repository to read a typed-object from a named Android file in the documents directory. * * @param Type Type of asset to read. - * @property fileName Name of file to read/write. + * @param fileName Name of file to read/write. * @param type Class of asset to read. */ -open class FileRepository<Type: Any>( +open class FileRepository<Type : Any>( private val fileName: String, - type: KClass<Type> + type: KClass<Type>, ) : Repository<Type>(type) { + @Throws(RepositoryError::class) override fun doStore(string: String, context: Context) { - try { - context.openFileOutput(fileName, 0)?.use { - doStore(string, it) + CoroutineScope(Dispatchers.IO).launch { + try { + context.openFileOutput(fileName, 0)?.use { + doStore(string, it) + } + } catch (exc: IOException) { + throw RepositoryError("Error saving settings: $fileName:", exc) } - } catch(exc: IOException) { - throw RepositoryError(TAG, "Error saving settings: $fileName:", exc) } } - override fun doLoad(context: Context): String? { - return try { - return context.openFileInput(fileName).use { - doLoad(it) - } - } catch (_: FileNotFoundException) { - null - } catch (exc: IOException) { - throw RepositoryError(TAG, "Error loading settings: $fileName:", exc) + override fun doLoad(context: Context): String? = try { + context.openFileInput(fileName).use { + doLoad(it) } + } catch (_: FileNotFoundException) { + null + } catch (exc: IOException) { + throw RepositoryError("Error loading settings: $fileName:", exc) } override fun doClear(context: Context) { context.deleteFile(fileName) } - - companion object { - private const val TAG = "FileRepository" - } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/PreferencesRepository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/PreferencesRepository.kt index a8ff81c5..51323757 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/PreferencesRepository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/PreferencesRepository.kt @@ -23,9 +23,9 @@ import kotlin.reflect.KClass * A repository to read a typed-object from shared preferences. * * @param Type Type of asset to read. - * @property key Preference key to target. + * @param key Preference key to target. * @param type Class of asset to read. - * @property fileName Name of preference file to use. Defaults to "com.nice.cxonechat.storefront". + * @param fileName Name of preference file to use. Defaults to "com.nice.cxonechat.storefront". */ open class PreferencesRepository<Type: Any>( private val key: String, 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 3a2db14c..858c2c36 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 @@ -31,7 +31,7 @@ import kotlin.reflect.KClass * @param Type type of data to be stored. * @param type class of data to be stored. */ -abstract class Repository<Type: Any>( +abstract class Repository<Type : Any>( private val type: KClass<Type>, ) { /** @@ -82,7 +82,7 @@ abstract class Repository<Type: Any>( try { stream.write(string.toByteArray(Charsets.UTF_8)) } catch (exc: IOException) { - throw RepositoryError(TAG, "Error encountered saving data", exc) + throw RepositoryError("Error encountered saving data", exc) } } @@ -94,10 +94,10 @@ abstract class Repository<Type: Any>( * @throws RepositoryError if any error is encountered while reading or parsing the stream. */ @Throws(RepositoryError::class) - protected fun doLoad(stream: InputStream) = try { + protected fun doLoad(stream: InputStream): String = try { stream.readBytes().toString(Charsets.UTF_8) } catch (exc: IOException) { - throw RepositoryError(TAG, "Error encountered loading data", exc) + throw RepositoryError("Error encountered loading data", exc) } /** @@ -140,8 +140,4 @@ abstract class Repository<Type: Any>( * @throws [JsonIOException] if any error is encountered during the conversion. */ private fun fromJson(string: String) = Gson().fromJson(string, type.java) - - companion object { - private const val TAG = "Repository" - } } diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/RepositoryError.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/RepositoryError.kt index 64ec02d7..633a99f3 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/RepositoryError.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/RepositoryError.kt @@ -15,15 +15,13 @@ package com.nice.cxonechat.sample.data.repository -import android.util.Log - /** * Exception thrown when an issue arises with saving or restoring repository data. * * @param message Description of error. * @param cause Underlying exception describing error. */ -class RepositoryError(message: String, cause: Throwable? = null): Exception(message, cause) { - constructor(tag: String, message: String, cause: Throwable? = null) : - this(message.also { Log.e(tag, it) }, cause) -} +class RepositoryError( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/SdkConfigurationListRepository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/SdkConfigurationListRepository.kt index 2ef6b42b..1fc70c20 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/SdkConfigurationListRepository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/SdkConfigurationListRepository.kt @@ -19,20 +19,22 @@ import android.content.Context import androidx.compose.runtime.Stable import com.nice.cxonechat.sample.data.models.SdkConfigurationList import com.nice.cxonechat.sample.data.models.SdkConfigurations -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton +import org.koin.core.annotation.Single /** * Repository to read the SdkConfigurationList from assets. * * @param context Application context to access assets. */ -@Singleton -class SdkConfigurationListRepository @Inject constructor(@ApplicationContext val context: Context) - : AssetRepository<SdkConfigurationList>("environment.json", SdkConfigurationList::class) { +@Single +class SdkConfigurationListRepository( + val context: Context, +) : AssetRepository<SdkConfigurationList>( + name = "environment.json", + type = SdkConfigurationList::class, +) { private val configurationListStore = MutableStateFlow<SdkConfigurations>(emptyList()) /** Predefined configurations from which we can choose. */ diff --git a/store/src/main/java/com/nice/cxonechat/sample/data/repository/StoreRepository.kt b/store/src/main/java/com/nice/cxonechat/sample/data/repository/StoreRepository.kt index 1b930827..3cd1a984 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/data/repository/StoreRepository.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/data/repository/StoreRepository.kt @@ -23,21 +23,18 @@ import com.nice.cxonechat.sample.data.models.Product import com.nice.cxonechat.sample.data.operations.add import com.nice.cxonechat.sample.data.operations.update import com.nice.cxonechat.sample.network.DummyJsonService.Companion.dummyJsonService -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map -import javax.inject.Inject -import javax.inject.Singleton +import org.koin.core.annotation.Single /** * Repository for the Store. Maintains the list of products, the shoppers cart, and user name information. * * @param context Application Context for preferences access. */ -@Singleton -class StoreRepository @Inject constructor( - @ApplicationContext +@Single +class StoreRepository( val context: Context ) { private val productsCache = MutableStateFlow<Pair<String, List<Product>>?>(null) 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 17505462..d6de689e 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 @@ -21,12 +21,10 @@ import com.nice.cxonechat.sample.data.models.UISettingsModel.Colors import com.nice.cxonechat.ui.composable.theme.ChatThemeDetails import com.nice.cxonechat.ui.composable.theme.Images import com.nice.cxonechat.ui.composable.theme.ThemeColors -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton +import org.koin.core.annotation.Single /** Current UI Settings as a mutable flow for compose theming. */ val UISettingsState = MutableStateFlow(UISettingsModel()) @@ -39,9 +37,27 @@ val UISettings: StateFlow<UISettingsModel> = UISettingsState.asStateFlow() * * @param context Application context for file access. */ -@Singleton -class UISettingsRepository @Inject constructor(@ApplicationContext val context: Context) - : FileRepository<UISettingsModel>("UISettings.json", UISettingsModel::class) { +@Single +class UISettingsRepository( + val context: Context, +) : FileRepository<UISettingsModel>( + fileName = "UISettings.json", + type = UISettingsModel::class +) { + private val Colors.asChatThemeColors: ThemeColors + get() = ThemeColors( + primary = primary, + onPrimary = onPrimary, + background = background, + onBackground = onBackground, + accent = accent, + onAccent = onAccent, + agentBackground = agentBackground, + agentText = agentText, + customerBackground = customerBackground, + customerText = customerText + ) + /** * Update the saved UI Settings. * @@ -70,24 +86,10 @@ class UISettingsRepository @Inject constructor(@ApplicationContext val context: super.clear(context) UISettingsState.value = UISettingsModel() } -} -private fun UISettingsModel.applyToChatSdk() { - ChatThemeDetails.darkColors = darkModeColors.asChatThemeColors - ChatThemeDetails.lightColors = lightModeColors.asChatThemeColors - if (logo != null) ChatThemeDetails.images = Images(logo) + private fun UISettingsModel.applyToChatSdk() { + ChatThemeDetails.darkColors = darkModeColors.asChatThemeColors + ChatThemeDetails.lightColors = lightModeColors.asChatThemeColors + ChatThemeDetails.images = Images(logo) + } } - -private val Colors.asChatThemeColors: ThemeColors - get() = ThemeColors( - primary = primary, - onPrimary = onPrimary, - background = background, - onBackground = onBackground, - accent = accent, - onAccent = onAccent, - agentBackground = agentBackground, - agentText = agentText, - customerBackground = customerBackground, - customerText = customerText - ) diff --git a/store/src/main/java/com/nice/cxonechat/sample/extensions/Lifecycle.kt b/store/src/main/java/com/nice/cxonechat/sample/extensions/Lifecycle.kt deleted file mode 100644 index 388c0718..00000000 --- a/store/src/main/java/com/nice/cxonechat/sample/extensions/Lifecycle.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2021-2023. 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.sample.extensions - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner - -/** - * Register a composable to be invoked on owner state changes. Primarily - * intended to track activity state changes within a composable. - * - * @param onEvent call back to make as the lifecycle state of the current - * lifecycle owner is updated. - */ -@Composable -fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { - val eventHandler = rememberUpdatedState(onEvent) - val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) - - DisposableEffect(lifecycleOwner.value) { - val lifecycle = lifecycleOwner.value.lifecycle - val observer = LifecycleEventObserver { owner, event -> - eventHandler.value(owner, event) - } - - lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) - } - } -} diff --git a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/di/ChatActivityModule.kt b/store/src/main/java/com/nice/cxonechat/sample/modules/StoreModule.kt similarity index 51% rename from chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/di/ChatActivityModule.kt rename to store/src/main/java/com/nice/cxonechat/sample/modules/StoreModule.kt index f00190ef..bd36e369 100644 --- a/chat-sdk-ui/src/main/java/com/nice/cxonechat/ui/di/ChatActivityModule.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/modules/StoreModule.kt @@ -13,25 +13,26 @@ * FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. */ -package com.nice.cxonechat.ui.di +package com.nice.cxonechat.sample.modules -import com.nice.cxonechat.Chat import com.nice.cxonechat.ChatInstanceProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerAndroid +import com.nice.cxonechat.log.ProxyLogger +import com.nice.cxonechat.sample.utilities.logging.FirebaseLogger +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single @Module -@InstallIn(ActivityRetainedComponent::class) -internal class ChatActivityModule { - @Provides - fun produceChatInstanceProvider() = ChatInstanceProvider.get() +@ComponentScan("com.nice.cxonechat.sample") +internal class StoreModule { + @Single + fun provideChatInstanceProvider() = ChatInstanceProvider.get() - @Provides - fun produceChat(chatInstanceProvider: ChatInstanceProvider): Chat { - return synchronized(chatInstanceProvider) { - requireNotNull(chatInstanceProvider.chat) - } - } + @Single + fun provideLogger(): Logger = ProxyLogger( + FirebaseLogger(), + LoggerAndroid("SampleApp") + ) } 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 0a13dc61..49142d8d 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 @@ -17,6 +17,8 @@ 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 okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET @@ -45,11 +47,18 @@ interface DummyJsonService { suspend fun product(@Path("productId") productId: String): Product companion object { + private val client by lazy { + OkHttpClient.Builder() + .socketFactory(TaggingSocketFactory) + .build() + } + /** singleton instance of DummyJsonService provided by retrofit. */ val dummyJsonService: DummyJsonService by lazy { Retrofit.Builder() .baseUrl("https://dummyjson.com") .addConverterFactory(GsonConverterFactory.create()) + .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 3fe5424a..b98669be 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 @@ -27,12 +27,12 @@ class ProductsParameterProvider: PreviewParameterProvider<List<Product>> { override val values: Sequence<List<Product>> get() = sequenceOf(items) - companion object { - private val items by lazy { - Gson().fromJson(json, ProductList::class.java).items + private companion object { + val items by lazy { + Gson().fromJson(JSON, ProductList::class.java).items } - private const val json = """ + private const val JSON = """ { "products": [ { 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 c51b1a47..ecdb04ef 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 @@ -55,7 +55,6 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import com.nice.cxonechat.sample.R.string -import com.nice.cxonechat.sample.StoreViewModel import com.nice.cxonechat.sample.data.models.Cart import com.nice.cxonechat.sample.data.models.Cart.Item import com.nice.cxonechat.sample.data.operations.isEmpty @@ -65,6 +64,7 @@ import com.nice.cxonechat.sample.extensions.bold import com.nice.cxonechat.sample.ui.theme.AppTheme import com.nice.cxonechat.sample.ui.theme.ContinueButton import com.nice.cxonechat.sample.ui.theme.ScreenWithScaffold +import com.nice.cxonechat.sample.viewModel.StoreViewModel /** * Defines the Cart Screen which displays a list of items in the cart as well @@ -89,7 +89,7 @@ object CartScreen : Screen { override fun navigation(navGraphBuilder: NavGraphBuilder, navHostController: NavHostController, viewModel: StoreViewModel) { navGraphBuilder.composable(route = routeFormat) { - viewModel.SendPageView("cart", "/cart") + viewModel.analyticsHandler.SendPageView("cart", "/cart") Screen( cart = viewModel.storeRepository.cart.collectAsState().value, 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 25f85c3e..108725b5 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 @@ -28,10 +28,10 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import com.nice.cxonechat.sample.R.string -import com.nice.cxonechat.sample.StoreViewModel import com.nice.cxonechat.sample.ui.theme.AppTheme import com.nice.cxonechat.sample.ui.theme.ContinueButton import com.nice.cxonechat.sample.ui.theme.ScreenWithScaffold +import com.nice.cxonechat.sample.viewModel.StoreViewModel /** * Define the ConfirmationScreen that appears after the Payment screen, confirming that @@ -47,7 +47,7 @@ object ConfirmationScreen : Screen { navGraphBuilder.composable( route = routeFormat, ) { - viewModel.SendPageView("confirmation", "/confirmation") + viewModel.analyticsHandler.SendPageView("confirmation", "/confirmation") Screen( onContinue = { diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/LoginDialog.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/LoginDialog.kt index 27cc21c3..3afe28f9 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/LoginDialog.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/LoginDialog.kt @@ -15,7 +15,7 @@ package com.nice.cxonechat.sample.ui -import android.util.Log +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.absolutePadding import androidx.compose.foundation.layout.wrapContentHeight @@ -24,7 +24,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager @@ -32,6 +32,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import com.nice.cxonechat.UserName +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.sample.R.string import com.nice.cxonechat.sample.data.models.ChatUserName import com.nice.cxonechat.sample.ui.theme.AppTheme @@ -40,17 +44,29 @@ import com.nice.cxonechat.sample.ui.theme.Dialog import com.nice.cxonechat.sample.ui.theme.OutlinedButton import com.nice.cxonechat.sample.ui.theme.TextField import com.nice.cxonechat.sample.utilities.Requirements.required +import org.koin.java.KoinJavaComponent.inject /** * Display the Login dialog to collect the users first and last name. * * @param userName Current [UserName] if any. * @param onAccept Invoked when the user accepts to dismiss the dialog and continue. + * @param analytics Additional [Composable] content which should be included in the dialog for purpose of tracking + * analytics data. */ @Composable -fun LoginDialog(userName: UserName?, onAccept: (UserName) -> Unit) { - var firstName by remember { mutableStateOf(userName?.firstName ?: "") } - var lastName by remember { mutableStateOf(userName?.lastName ?: "") } +fun LoginDialog( + userName: UserName?, + onAccept: (UserName) -> Unit, + @SuppressLint( + "ComposableLambdaParameterNaming" // This is not intended for actual content. + ) + analytics: (@Composable () -> Unit)? = null, +) { + var firstName by rememberSaveable { mutableStateOf(userName?.firstName ?: "") } + var lastName by rememberSaveable { mutableStateOf(userName?.lastName ?: "") } + + analytics?.invoke() AppTheme.Dialog( modifier = Modifier.wrapContentHeight(), @@ -84,7 +100,7 @@ fun LoginDialog(userName: UserName?, onAccept: (UserName) -> Unit) { onDone = { focusManager.clearFocus() val newUserName = ChatUserName(lastName = lastName, firstName = firstName) - if(newUserName.valid) { + if (newUserName.valid) { onAccept(newUserName) } } @@ -99,7 +115,15 @@ fun LoginDialog(userName: UserName?, onAccept: (UserName) -> Unit) { @Preview @Composable private fun LoginDialogPreview() { - LoginDialog(userName = null) { userName -> - Log.d("LoginDialog", "finish: $userName") - } + val logger: Logger by inject(Logger::class.java) + val loggerScope = LoggerScope("LoginDialogPreview", logger) + + LoginDialog( + userName = null, + onAccept = { userName -> + loggerScope.scope("onAccept") { + debug("finish: $userName") + } + } + ) } 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 87029318..42a5f44e 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 @@ -35,11 +35,11 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import com.nice.cxonechat.sample.R.string -import com.nice.cxonechat.sample.StoreViewModel import com.nice.cxonechat.sample.data.operations.total import com.nice.cxonechat.sample.ui.theme.AppTheme import com.nice.cxonechat.sample.ui.theme.ContinueButton import com.nice.cxonechat.sample.ui.theme.ScreenWithScaffold +import com.nice.cxonechat.sample.viewModel.StoreViewModel /** * Payment screen to dummy collect payment information. @@ -55,11 +55,11 @@ object PaymentScreen : Screen { ) { val cart = viewModel.storeRepository.cart.collectAsState().value - viewModel.SendPageView("payment", "/payment") + viewModel.analyticsHandler.SendPageView("payment", "/payment") Screen( onContinue = { - viewModel.sendConversion("sale", cart.total) + viewModel.analyticsHandler.sendConversion("sale", cart.total) viewModel.storeRepository.resetCart() ConfirmationScreen.navigateTo(navHostController) } 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 4efe1814..74918a00 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 @@ -15,6 +15,7 @@ package com.nice.cxonechat.sample.ui +import android.annotation.SuppressLint import android.app.Activity import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -37,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -57,7 +59,6 @@ import androidx.navigation.compose.composable import androidx.navigation.navArgument import coil.compose.AsyncImage import com.nice.cxonechat.sample.R.string -import com.nice.cxonechat.sample.StoreViewModel import com.nice.cxonechat.sample.data.models.Product import com.nice.cxonechat.sample.extensions.asCurrency import com.nice.cxonechat.sample.extensions.bold @@ -65,6 +66,9 @@ import com.nice.cxonechat.sample.previewproviders.ProductsParameterProvider import com.nice.cxonechat.sample.ui.theme.AppTheme import com.nice.cxonechat.sample.ui.theme.AppTheme.space import com.nice.cxonechat.sample.ui.theme.ScreenWithScaffold +import com.nice.cxonechat.sample.viewModel.AnalyticsHandler.PageInfo +import com.nice.cxonechat.sample.viewModel.StoreViewModel +import com.nice.cxonechat.sample.viewModel.UiState /** * The Product List Screen displaying a list of available products. @@ -81,7 +85,11 @@ object ProductListScreen : Screen { private fun routeTo(category: String = defaultCategory) = "products/$category" - override fun navigation(navGraphBuilder: NavGraphBuilder, navHostController: NavHostController, viewModel: StoreViewModel) { + override fun navigation( + navGraphBuilder: NavGraphBuilder, + navHostController: NavHostController, + viewModel: StoreViewModel, + ) { navGraphBuilder.composable( route = routeFormat, arguments = listOf( @@ -94,23 +102,21 @@ object ProductListScreen : Screen { val context = LocalContext.current val category = navBackStackEntry.category var products by remember { mutableStateOf<List<Product>>(listOf()) } - var attempt by remember { mutableStateOf(0) } + var attempt by remember { mutableIntStateOf(0) } var error by rememberSaveable { mutableStateOf<String?>(null) } val cart = viewModel.storeRepository.cart.collectAsState().value + val uiState = viewModel.uiState.collectAsState().value - viewModel.SendPageView("products?$category", "/products/$category") + // setup to generate page views, but only if no dialog is displayed. + viewModel.analyticsHandler.SendPageView(pageInfoForState(uiState, category), uiState, error) - LaunchedEffect(category, attempt) { - viewModel - .storeRepository - .getProducts(category) - .onSuccess { - products = it - } - .onFailure { - error = it.localizedMessage ?: context.getString(string.unknown_error) - } - } + LoadCategories( + viewModel = viewModel, + category = category, + attempt = attempt, + onError = { error = it }, + onSuccess = { products = it } + ) Screen( products, @@ -125,19 +131,43 @@ object ProductListScreen : Screen { }, ) - when { - error != null -> - ErrorAlert( - message = error ?: context.getString(string.unknown_error), - onDismiss = { (context as? Activity)?.finishAffinity() } - ) { - error = null - attempt += 1 - } + error?.let { message -> + ErrorAlert( + message = message, + onDismiss = { (context as? Activity)?.finishAffinity() } + ) { + error = null + attempt += 1 + } } } } + private fun pageInfoForState(state: UiState, category: String) = if (state.isInDialog) { + null + } else { + PageInfo("products?$category", "/products/$category") + } + + @Composable + private fun LoadCategories( + viewModel: StoreViewModel, + category: String, + attempt: Int, + onError: (String?) -> Unit, + onSuccess: (List<Product>) -> Unit + ) { + LaunchedEffect(category, attempt) { + viewModel + .storeRepository + .getProducts(category) + .onSuccess(onSuccess) + .onFailure { + onError(it.localizedMessage) + } + } + } + /** * Navigate to this screen using the passed [NavHostController]. * @@ -155,6 +185,9 @@ object ProductListScreen : Screen { onUiSettings: () -> Unit, onSdkSettings: () -> Unit, onLogout: () -> Unit, + @SuppressLint( + "ComposableLambdaParameterNaming" // This isn't intended to be a re-usable composable + ) showCart: @Composable RowScope.() -> Unit, ) { AppTheme.ScreenWithScaffold( 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 606090fa..0d2b3ecd 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 @@ -15,6 +15,7 @@ package com.nice.cxonechat.sample.ui +import android.annotation.SuppressLint import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -52,7 +53,6 @@ import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.nice.cxonechat.sample.R.string -import com.nice.cxonechat.sample.StoreViewModel import com.nice.cxonechat.sample.data.models.Product import com.nice.cxonechat.sample.extensions.asCurrency import com.nice.cxonechat.sample.extensions.bold @@ -62,6 +62,7 @@ import com.nice.cxonechat.sample.ui.components.RatingBar import com.nice.cxonechat.sample.ui.theme.AppTheme import com.nice.cxonechat.sample.ui.theme.AppTheme.space import com.nice.cxonechat.sample.ui.theme.ScreenWithScaffold +import com.nice.cxonechat.sample.viewModel.StoreViewModel /** * The Product screen, allowing a single product to be displayed. @@ -92,7 +93,7 @@ object ProductScreen : Screen { val cart = viewModel.storeRepository.cart.collectAsState().value val context = LocalContext.current - viewModel.SendPageView(title = "product?$productId", url = "/product/$productId") + viewModel.analyticsHandler.SendPageView("product?$productId", "/product/$productId") LaunchedEffect(productId, attempt) { viewModel.storeRepository.getProduct(productId) @@ -131,6 +132,9 @@ object ProductScreen : Screen { internal fun Screen( product: Product?, addToCart: (Product) -> Unit, + @SuppressLint( + "ComposableLambdaParameterNaming" // This isn't intended to be a re-usable composable + ) showCart: @Composable RowScope.() -> Unit, ) { AppTheme.ScreenWithScaffold( diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/Screen.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/Screen.kt index 2810ae52..0200a947 100644 --- a/store/src/main/java/com/nice/cxonechat/sample/ui/Screen.kt +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/Screen.kt @@ -17,7 +17,7 @@ package com.nice.cxonechat.sample.ui import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController -import com.nice.cxonechat.sample.StoreViewModel +import com.nice.cxonechat.sample.viewModel.StoreViewModel /** * Generic definition of a screen to be included in a NavGraphBuilder/NavHostController pair. @@ -30,5 +30,9 @@ interface Screen { * @param navHostController NavHostController to use for navigation. * @param viewModel StoreViewModel associated with attach activity. */ - fun navigation(navGraphBuilder: NavGraphBuilder, navHostController: NavHostController, viewModel: StoreViewModel) + fun navigation( + navGraphBuilder: NavGraphBuilder, + navHostController: NavHostController, + viewModel: StoreViewModel, + ) } diff --git a/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Images.kt b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Images.kt new file mode 100644 index 00000000..105dbddb --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/ui/theme/Images.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021-2023. 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.sample.ui.theme + +import com.nice.cxonechat.sample.R.mipmap +import com.nice.cxonechat.ui.composable.theme.Images + +/** + * Default images used in the application. + */ +object Images : Images { + override val logo: Any = mipmap.ic_launcher +} 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 2fa7bb28..3569dc25 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 @@ -16,7 +16,6 @@ package com.nice.cxonechat.sample.ui.theme import android.app.Activity -import android.content.Intent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding @@ -36,7 +35,6 @@ import com.nice.cxonechat.sample.ui.theme.Strings.content import com.nice.cxonechat.sample.ui.theme.Strings.title import com.nice.cxonechat.ui.ChatActivity import kotlinx.coroutines.launch -import com.nice.cxonechat.ui.R.anim as ChatUiAnimations /** * Displays a screen in an AppTheme Scaffold. @@ -84,13 +82,7 @@ fun AppTheme.ScreenWithScaffold( } val onOpenChat: (() -> Unit) = { - with(context) { - startActivity(Intent(context, ChatActivity::class.java)) - (this as? Activity)?.overridePendingTransition( - ChatUiAnimations.present_chat, - ChatUiAnimations.present_host - ) - } + (context as? Activity)?.run(ChatActivity::startChat) } Scaffold( @@ -103,7 +95,11 @@ fun AppTheme.ScreenWithScaffold( ) }, floatingActionButton = { ChatFab(onClick = onOpenChat) }, - drawerContent = { drawerContent?.invoke { closeDrawer() } }, + drawerContent = drawerContent?.let { + { + drawerContent.invoke { closeDrawer() } + } + }, ) { paddingValues -> Box( modifier = Modifier 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 336d3c2b..28335d02 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 @@ -44,9 +44,11 @@ import androidx.compose.runtime.setValue 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.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.ImageLoader import coil.compose.AsyncImage import com.nice.cxonechat.sample.R.string import com.nice.cxonechat.sample.data.models.UISettingsModel @@ -58,7 +60,7 @@ import com.nice.cxonechat.sample.ui.theme.Dialog import com.nice.cxonechat.sample.ui.theme.LocalSpace import com.nice.cxonechat.sample.ui.theme.MultiToggleButton import com.nice.cxonechat.sample.ui.theme.OutlinedButton -import com.nice.cxonechat.ui.composable.theme.ChatThemeDetails +import kotlinx.coroutines.Dispatchers /** * Edit the UI Settings currently in place. @@ -66,6 +68,7 @@ import com.nice.cxonechat.ui.composable.theme.ChatThemeDetails * @param value current settings * @param onDismiss close the dialog with no changes. * @param pickImage action which when finished will return string which will be resolvable as an image. + * @param onReset ui settings should be reset to a default state. * @param onConfirm new settings have been accepted, change them as necessary. */ @Composable @@ -73,6 +76,7 @@ fun UISettingsDialog( value: UISettingsModel, onDismiss: () -> Unit, pickImage: ((String?) -> Unit) -> Unit, + onReset: () -> Unit, onConfirm: (UISettingsModel) -> Unit, ) { var current by remember { mutableStateOf(value) } @@ -96,7 +100,7 @@ fun UISettingsDialog( } } ) { - SettingsView(settings = current, pickImage, onChanged = { current = it }) + SettingsView(settings = current, pickImage, onChanged = { current = it }, onReset = onReset) if (error != null) { AlertDialog( @@ -118,6 +122,7 @@ private fun SettingsView( settings: UISettingsModel, pickImage: ((String?) -> Unit) -> Unit, onChanged: (UISettingsModel) -> Unit, + onReset: () -> Unit, ) { Column( modifier = Modifier @@ -134,9 +139,7 @@ private fun SettingsView( .fillMaxWidth() .padding(top = space.large), ) { - AppTheme.OutlinedButton(text = stringResource(string.set_defaults)) { - onChanged(UISettingsModel()) - } + AppTheme.OutlinedButton(text = stringResource(string.set_defaults), onClick = onReset) } } } @@ -180,8 +183,9 @@ private fun ImagePicker( modifier = modifier, ) { AsyncImage( + imageLoader = ImageLoader.Builder(LocalContext.current).interceptorDispatcher(Dispatchers.IO).build(), placeholder = rememberVectorPainter(image = Icons.Default.Image), - model = settings.logo ?: ChatThemeDetails.images.logo, + model = settings.logo, contentDescription = null, modifier = Modifier .padding(LocalSpace.current.medium) @@ -190,7 +194,7 @@ private fun ImagePicker( Button( onClick = { pickImage { pickedImage -> - onChanged(settings.copy(logo = pickedImage)) + onChanged(settings.copy(storedLogo = pickedImage)) } }, modifier = Modifier @@ -269,7 +273,7 @@ private fun UISettingsDialogPreview() { .fillMaxWidth(1f) .fillMaxHeight(1f) ) { - UISettingsDialog(current, {}, {}, {}) + UISettingsDialog(current, {}, {}, {}, {}) } } } 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 new file mode 100644 index 00000000..59aabdb8 --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/utilities/RuleBasedPenalty.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2021-2023. 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.sample.utilities + +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.os.Handler +import android.os.Looper +import android.os.PatternMatcher +import android.os.strictmode.Violation +import android.util.Log +import androidx.annotation.RequiresApi +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Action +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Companion.Actions.allow +import com.nice.cxonechat.sample.utilities.RuleBasedPenalty.Predicate +import kotlin.reflect.KClass + +/** + * Rules-based handling of Android StrictMode Violations. + * + * @param rules Defined rules to enforce on any violations that arise. The rules will be matched in + * sequence with only the first match being performed. + */ +@RequiresApi(VERSION_CODES.P) +class RuleBasedPenalty( + private vararg val rules: Rule, +) { + /** + * A test predicate to match violations. + */ + fun interface Predicate { + /** + * Test [violation] for match. + * + * @param violation [Violation] to test. + * @return True iff [violation] matches this predicate. + */ + fun test(violation: Violation): Boolean + } + + /** + * An action to perform once a predicate has been matched. + */ + fun interface Action { + /** + * Perform the action on behalf of a named policy for a specific [Violation]. + * + * @param violation Details of the violation that occurred. + */ + fun perform(violation: Violation) + } + + /** + * A rule to be applied to violations that occur. + * + * @param predicate Predicate to test violations. + * @param action Action to take on matching violations. + */ + class Rule( + private val predicate: Predicate, + private val action: Action, + ) { + /** + * Test [violation] for match. + * + * @param violation [Violation] to test. + * @return True iff [violation] matches this predicate. + */ + fun matches(violation: Violation) = predicate.test(violation) + + /** + * Perform the action on behalf of a named policy for a specific [Violation]. + * + * @param violation Details of the violation that occurred. + */ + fun perform(violation: Violation) { + action.perform(violation) + } + } + + /** + * A violation has occurred, apply the rules. + * + * Each of the rules will be tested in sequence using [Rule.matches]. The first matching rule will be + * applied per [Rule.perform]. + * @param violation The violation that has occurred. + */ + fun perform(violation: Violation) { + rules + .firstOrNull { it.matches(violation) } + ?.perform(violation) + } + + companion object { + /** + * Create a rule to allow violations matching an exception. + * + * @param predicate [Predicate] to match. + */ + fun allow(predicate: Predicate) = Rule( + predicate = predicate, + action = allow() + ) + + /** A collection of common [Predicate]. */ + object Predicates { + /** + * Match any violation. + * + * @return true + */ + fun any() = Predicate { true } + + /** + * Match a violation based on it's stack trace containing at least one instance + * of a method in [methods] being defined by a class named [className]. + * + * @param className Class name to match. + * @param methods List of method names to match. + * @return a Predicate that will return true iff a single entry in the stack trace + * of [violation] matches both [className] or [methods]. If [methods] is empty, all + * method names match. + */ + fun classNamed( + className: String, + vararg methods: String, + ) = Predicate { violation -> + violation.stackTrace.toList().any { element -> + className == element.className && + (methods.isEmpty() || methods.contains(element.methodName)) + } + } + + /** + * Match a violation based on it's stack trace containing at least one instance + * of a matching class. + * + * @param classMatcher A [PatternMatcher] which will be used to match classes. + * + * @return A Predicate that will return true iff a single entry in the stack trace + * of [violation] matches the [classMatcher]. + */ + fun classNamed( + classMatcher: PatternMatcher, + ) = Predicate { violation -> + violation.stackTrace + .map(StackTraceElement::getClassName) + .any(classMatcher::match) + } + + /** + * Match a violation by class of violation. + * + * @param failures Violation subclasses to match + * @return a [Predicate] that will only match if the class of the violation matches + * one of [failures]. + */ + fun violation( + vararg failures: KClass<out Violation> + ) = Predicate { violation -> + failures.contains(violation::class) + } + + /** + * Match only if all included predicates match. + * + * @param predicates list of predicates to match. + * @return a [Predicate] that will only match a violation if all of the + * predicates in [predicates] match. + */ + fun allOf(vararg predicates: Predicate) = Predicate { violation -> + !predicates.any { + !it.test(violation) + } + } + + /** + * Match if any of the included predicates matches. + * + * @param predicates list of predicates to match. + * @return a [Predicate] that will only match a violation if at least one + * of the predicates in [predicates] matches. Once a predicate has matched, + * no further predicates will be tested on violation. + */ + fun anyOf(vararg predicates: Predicate) = Predicate { violation -> + predicates.any { it.test(violation) } + } + } + + /** A collection of common [Action]. */ + object Actions { + /** + * Perform each included [Action] in order. + * + * @param actions Actions to perform. + */ + fun allOf(vararg actions: Action) = Action { violation -> + actions.forEach { it.perform(violation) } + } + + /** + * A no op that precludes further matching. + */ + fun allow() = Action { _ -> + } + + /** + * Log a violation. + * + * @param policyName Policy name to attach to the log message. + */ + fun log(policyName: String) = Action { violation -> + if (VERSION.SDK_INT >= VERSION_CODES.P) { + Log.e("StrictMode", "$policyName violation", violation) + } else { + Log.e("StrictMode", "$policyName violation: $violation") + } + } + + /** + * Terminate the application if the rule is matched. + */ + fun terminate() = Action { violation -> + Looper.getMainLooper().let(::Handler).post { + throw violation + } + } + } + } +} diff --git a/store/src/main/java/com/nice/cxonechat/sample/utilities/logging/FirebaseLogger.kt b/store/src/main/java/com/nice/cxonechat/sample/utilities/logging/FirebaseLogger.kt new file mode 100644 index 00000000..d15d5e1a --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/utilities/logging/FirebaseLogger.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021-2023. 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.sample.utilities.logging + +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import com.nice.cxonechat.log.Level +import com.nice.cxonechat.log.Level.All +import com.nice.cxonechat.log.Level.Custom +import com.nice.cxonechat.log.Level.Debug +import com.nice.cxonechat.log.Level.Error +import com.nice.cxonechat.log.Level.Info +import com.nice.cxonechat.log.Level.Verbose +import com.nice.cxonechat.log.Level.Warning +import com.nice.cxonechat.log.Logger + +/** + * [Logger] implementation which redirects logged messages to Firebase [crashlytics]. + * + * @param minLevel Minimal [Level] of message to be logged to Firebase [crashlytics] for additional context when + * exception is recorded. The default value is [Level.Info]. + * @param minExceptionLevel Minimal [Level] of logged exception in order for it to be recorded to Firebase [crashlytics] + * as non-fatal exception. The default value is [Level.Error]. + */ +class FirebaseLogger( + private val minLevel: Level = Info, + private val minExceptionLevel: Level = Error, +) : Logger { + + private fun Level.toChar(): Char = when (this) { + All -> 'A' + Verbose -> 'V' + Debug -> 'D' + Info -> 'I' + Warning -> 'W' + Error -> 'E' + is Custom -> 'C' + } + + override fun log(level: Level, message: String, throwable: Throwable?) { + if (level < minLevel) return + with(Firebase.crashlytics) { + log("${level.toChar()}:$message") + if (throwable != null && level >= minExceptionLevel) recordException(throwable) + } + } +} diff --git a/store/src/main/java/com/nice/cxonechat/sample/viewModel/AnalyticsHandler.kt b/store/src/main/java/com/nice/cxonechat/sample/viewModel/AnalyticsHandler.kt new file mode 100644 index 00000000..3f198da0 --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/viewModel/AnalyticsHandler.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2021-2023. 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.sample.viewModel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import com.nice.cxonechat.ChatEventHandler +import com.nice.cxonechat.ChatEventHandlerActions.conversion +import com.nice.cxonechat.ChatEventHandlerActions.pageView +import com.nice.cxonechat.ChatEventHandlerActions.pageViewEnded +import com.nice.cxonechat.ChatInstanceProvider +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.info +import com.nice.cxonechat.log.scope +import java.util.Date + +/** + * Manage page view events. + * + * @param chatProvider [ChatInstanceProvider] to use for actually sending page view + * and page view ended events. + * @param logger [Logger] used as base for [LoggerScope]. + */ +class AnalyticsHandler( + private val chatProvider: ChatInstanceProvider, + logger: Logger, +) : LoggerScope by LoggerScope(TAG, logger) { + private val events: ChatEventHandler? + get() = chatProvider.chat?.events() + + /** + * Recorded details of pageView and pageViewEnded events. + * + * @property title title of viewed page. + * @property url url of viewed page. + */ + @Immutable + data class PageInfo(val title: String, val url: String) + + private var lastPageInfo: PageInfo? = null + private var pageViewEndedNeeded = false + + /** the associated activity has received onResume. */ + fun onResume() { + lastPageInfo?.let(::sendPageView) + } + + /** the associated activity has received onPause. */ + fun onPause() { + sendPageViewEnded() + } + + /** + * Send a conversion event to the analytics service. + * + * @param type application-specific "type" of conversion. + * @param amount dollar amount of conversion. + * @param date date of conversion, defaults to now. + */ + fun sendConversion(type: String, amount: Double, date: Date = Date()) { + events?.conversion(type, amount, date) + } + + /** + * Record PageView and PageViewEnded event handler with a given page title and url. + * + * @see [SendPageView] + * + * @param title Unique page title to be recorded. + * @param url Unique page url to be recorded. + * @param keys Any additional key values that could signify a page change. Examples might be + * values that trigger dialogs which should be considered distinct page views such as + * [LoginDialog]. + */ + @Composable + fun SendPageView(title: String, url: String, vararg keys: Any?) { + SendPageView(pageInfo = PageInfo(title, url), *keys) + } + + /** + * Record page view info to appropriately generate pageView and pageViewEnded events. + * + * @param pageInfo Page info (title and url) to be recorded. If null is passed, no page view + * should be generated, presumably because it is being separately handled by a dialog considered + * a distinct page view. + * @param keys Any additional key values that could signify a page change. Examples might be + * values that trigger dialogs which should be considered distinct page views such as + * [LoginDialog]. + */ + @Composable + fun SendPageView(pageInfo: PageInfo?, vararg keys: Any?) { + DisposableEffect(pageInfo, *keys) { + if (pageInfo != lastPageInfo) { + sendPageViewEnded() + } + + if (pageInfo != null) { + sendPageView(pageInfo, Date()) + } + + onDispose { + if (lastPageInfo == pageInfo) { + sendPageViewEnded() + } + } + } + } + + private fun sendPageView(pageInfo: PageInfo, date: Date = Date()) = scope("sendPageView") { + info("sendPageView(title=${pageInfo.title}, url=${pageInfo.url})") + events?.pageView(pageInfo.title, pageInfo.url, date) + lastPageInfo = pageInfo + pageViewEndedNeeded = true + } + + private fun sendPageViewEnded() = scope("sendPageViewEnded") { + val last = lastPageInfo ?: return + + if (pageViewEndedNeeded) { + info("sendPageViewEnded(title=${last.title}, url=${last.url})") + events?.pageViewEnded(last.title, last.url, Date()) + pageViewEndedNeeded = false + } + } + + companion object { + private const val TAG = "PageViewHandler" + } +} diff --git a/store/src/main/java/com/nice/cxonechat/sample/viewModel/ChatSettingsHandler.kt b/store/src/main/java/com/nice/cxonechat/sample/viewModel/ChatSettingsHandler.kt new file mode 100644 index 00000000..d1580b5e --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/viewModel/ChatSettingsHandler.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2021-2023. 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.sample.viewModel + +import android.content.Context +import com.amazon.identity.auth.device.AuthError +import com.amazon.identity.auth.device.api.Listener +import com.amazon.identity.auth.device.api.authorization.AuthorizationManager +import com.google.firebase.ktx.Firebase +import com.google.firebase.messaging.ktx.messaging +import com.nice.cxonechat.Authorization +import com.nice.cxonechat.ChatInstanceProvider +import com.nice.cxonechat.ChatInstanceProvider.DeviceTokenProvider +import com.nice.cxonechat.UserName +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.error +import com.nice.cxonechat.log.info +import com.nice.cxonechat.log.scope +import com.nice.cxonechat.sample.data.models.ChatSettings +import com.nice.cxonechat.sample.data.models.SdkConfiguration +import com.nice.cxonechat.sample.data.models.toChatAuthorization +import com.nice.cxonechat.sample.data.models.toChatUserName +import com.nice.cxonechat.sample.data.repository.ChatSettingsRepository + +/** + * Coordinate chat settings changes between the ChatSettingsRepository and the ChatInstanceProvider. + * + * @param context Android context for chat updates. + * @param chatProvider ChatInstanceProvider to manage. + * @param chatSettingsRepository ChatSettingsRepository to manage. + * @param logger [Logger] used as base for [LoggerScope]. + */ +class ChatSettingsHandler( + private val context: Context, + private val chatProvider: ChatInstanceProvider, + private val chatSettingsRepository: ChatSettingsRepository, + logger: Logger, +) : LoggerScope by LoggerScope(TAG, logger) { + private val settings: ChatSettings? + get() = chatSettingsRepository.settings.value + + /** + * Set the sdk configuration to use for future attempts, if the configuration + * has changed, a new connection will be established. + * + * @param sdkConfiguration new configuration to use. + */ + fun setConfiguration(sdkConfiguration: SdkConfiguration) { + apply( + settings?.copy( + sdkConfiguration = sdkConfiguration, + authorization = null, + userName = null, + ) ?: ChatSettings(sdkConfiguration, null, null) + ) + } + + /** + * Set the user name for future connections. + * + * @param userName New user name to use. + */ + fun setUserName(userName: UserName) { + val chatUserName = userName.toChatUserName + settings + ?.copy(userName = chatUserName) + ?.let(chatSettingsRepository::use) + ?: chatSettingsRepository.clear() + + chatProvider.setUserName(chatUserName) + } + + /** + * Set the authorization to use for future connections. + * + * A new connection will be established + * + * @param authorization new authorization to use. + */ + fun setAuthorization(authorization: Authorization) { + apply( + settings?.copy( + authorization = authorization.toChatAuthorization, + ) + ) + } + + /** + * Clear any saved user authentication credentials from the ChatProvider + * and saved storage. + */ + fun clearAuthentication() = scope("clearAuthentication") { + AuthorizationManager.signOut(context, LoggingSignoutListener()) + + apply( + settings?.copy(authorization = null, userName = null) + ) + } + + /** + * Apply save a set of settings changes and apply them to the chatProvider. + * + * @param settings ChatSettings to apply. + */ + private fun apply(settings: ChatSettings?) = scope("apply") { + settings?.let(chatSettingsRepository::use) ?: chatSettingsRepository.clear() + + chatProvider.signOut() + + chatProvider.configure(context) { + configuration = settings?.sdkConfiguration?.asSocketFactoryConfiguration + userName = settings?.userName + authorization = settings?.authorization + deviceTokenProvider = FirebaseTokenProvider() + } + } + + companion object { + private const val TAG = "ChatSettingsHandler" + } + + private inner class FirebaseTokenProvider : + DeviceTokenProvider, + LoggerScope by LoggerScope<DeviceTokenProvider>(this) { + override fun requestDeviceToken(onComplete: (String) -> Unit): Unit = + scope("requestDeviceToken") { + Firebase + .messaging + .token + .addOnSuccessListener(onComplete) + .addOnFailureListener { + error("Firebase.messaging.token failed: $it") + } + } + } + + private inner class LoggingSignoutListener : + Listener<Void, AuthError>, + LoggerScope by LoggerScope<LoggingSignoutListener>(this) { + override fun onSuccess(ignore: Void?) = scope("onSuccess") { + info("loginWithAmazon.logout success") + } + + override fun onError(error: AuthError?) = scope("onError") { + info("loginWithAmazon.logout failure: ${error?.message}") + } + } +} diff --git a/store/src/main/java/com/nice/cxonechat/sample/viewModel/StoreViewModel.kt b/store/src/main/java/com/nice/cxonechat/sample/viewModel/StoreViewModel.kt new file mode 100644 index 00000000..fa22c736 --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/viewModel/StoreViewModel.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2021-2023. 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.sample.viewModel + +import android.app.Application +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.lifecycle.AndroidViewModel +import com.nice.cxonechat.ChatInstanceProvider +import com.nice.cxonechat.ChatState +import com.nice.cxonechat.UserName +import com.nice.cxonechat.exceptions.RuntimeChatException +import com.nice.cxonechat.log.Logger +import com.nice.cxonechat.log.LoggerScope +import com.nice.cxonechat.log.debug +import com.nice.cxonechat.log.error +import com.nice.cxonechat.log.scope +import com.nice.cxonechat.log.warning +import com.nice.cxonechat.sample.data.models.ChatSettings +import com.nice.cxonechat.sample.data.repository.ChatSettingsRepository +import com.nice.cxonechat.sample.data.repository.SdkConfigurationListRepository +import com.nice.cxonechat.sample.data.repository.StoreRepository +import com.nice.cxonechat.sample.data.repository.UISettingsRepository +import com.nice.cxonechat.sample.extensions.Ignored +import com.nice.cxonechat.sample.viewModel.UiState.Configuration +import com.nice.cxonechat.sample.viewModel.UiState.Initial +import com.nice.cxonechat.sample.viewModel.UiState.Login +import com.nice.cxonechat.sample.viewModel.UiState.OAuth +import com.nice.cxonechat.sample.viewModel.UiState.Prepared +import com.nice.cxonechat.sample.viewModel.UiState.Preparing +import com.nice.cxonechat.sample.viewModel.UiState.UiSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.android.annotation.KoinViewModel + +/** + * ViewModel for the StoreActivity. + */ +@Stable +@Suppress("LongParameterList") +@KoinViewModel +class StoreViewModel( + application: Application, + /** store repository containing cart and product information. */ + val storeRepository: StoreRepository, + /** sdk configuration repository containing list of predefined SDK configurations. */ + val sdkConfigurationListRepository: SdkConfigurationListRepository, + /** chat configuration and settings repository containing the current configuration. */ + val chatSettingsRepository: ChatSettingsRepository, + /** UI settings repository saving and managing UI configuration. */ + val uiSettingsRepository: UISettingsRepository, + /** chat repository containing current chat. */ + val chatProvider: ChatInstanceProvider, + /** logger for store messages. */ + logger: Logger, +) : AndroidViewModel(application), LoggerScope by LoggerScope(TAG, logger) { + private val uiStateStore = MutableStateFlow<UiState>(Initial) + + private val context + get() = getApplication() as Context + + /** Page view handler managing page view and page view ended events. */ + val analyticsHandler = AnalyticsHandler(chatProvider, this) + + /** Chat settings handler to manage settings updates into provider. */ + val chatSettingsHandler = ChatSettingsHandler(context, chatProvider, chatSettingsRepository, this) + + /** Current UI State. */ + @Stable + val uiState = uiStateStore.asStateFlow() + + /** listener to chat instance changes. */ + private val listener = Listener().also(chatProvider::addListener) + + init { + chatSettingsRepository.load() + uiSettingsRepository.load() + sdkConfigurationListRepository.load() + } + + override fun onCleared() { + super.onCleared() + chatProvider.removeListener(listener) + } + + /** + * Update UI State. + * + * @param state new UI State. + */ + fun setUiState(state: UiState) = scope("setUiState") { + debug("uiState: ${uiState.value} -> $state") + uiStateStore.value = state + } + + /** + * Start up chat as required/possible. + */ + private fun startChat() { + listener.onChatStateChanged(chatProvider.chatState) + + if (chatSettingsRepository.settings.value == null) { + setUiState(Configuration(this)) + } else if (uiState.value is Configuration) { + chatProvider.prepare(context) + } + } + + /** + * present SDK configuration alert. + */ + fun presentConfigurationDialog() { + setUiState(Configuration(this)) + } + + /** Display the UI Settings dialog. */ + fun presentUiSettings() { + setUiState(UiSettings(this)) + } + + /** + * Set the user name for future connections. + * + * @param userName New user name to use. + */ + fun setUserName(userName: UserName) { + chatSettingsHandler.setUserName(userName) + + if (chatProvider.chatState == ChatState.INITIAL) { + chatProvider.prepare(context) + } else { + listener.onChatStateChanged(chatProvider.chatState) + } + } + + /** + * A connection has been established, check it's validity based on: + * * if authentication is enabled, make sure we have appropriate OAuth details + * * otherwise make sure we have a valid user name. + */ + private fun onConnected() = scope("onConnected") { + val settings = chatSettingsRepository.settings.value + val isAuthorizationEnabled = chatProvider.chat?.configuration?.isAuthorizationEnabled + val state = currentUiState(this, settings, isAuthorizationEnabled) ?: return@scope + val currentState = uiState.value + if (!(state == Prepared && (currentState is UiSettings || currentState is Configuration))) { + setUiState(state) + } + } + + private fun currentUiState( + loggerScope: LoggerScope, + settings: ChatSettings?, + isAuthorizationEnabled: Boolean?, + ) = when (isAuthorizationEnabled) { + null -> { + loggerScope.error("No chat when in CONNECTED state.") + null + } + + true -> if (settings?.authorization != null) { + Prepared + } else { + OAuth + } + + false -> if (settings?.userName != null) { + Prepared + } else { + Login(this) + } + } + + /** + * Log out, clearing out all configuration-dependent information. + * + * Clears and Resets: + * * Chat connection + * * UI Settings + * * Store cart and user information + */ + fun logout() { + chatSettingsHandler.clearAuthentication() + + // This needs to be *after* all the settings are cleared out or we + // immediately reconnect using the same information. + chatProvider.signOut() + } + + /** + * Called by hosting [android.app.Activity.onResume] to generate appropriate page view events when + * the application is resumed and to restart the chat sdk and enable analytics. + */ + fun onResume() { + startChat() + } + + private inner class Listener: ChatInstanceProvider.Listener { + /** + * Send a pending page view if chat is just now established. + */ + override fun onChatStateChanged(chatState: ChatState) { + // If the chat has now connected, see if we need to send authorization + when (chatState) { + ChatState.INITIAL -> setUiState(Configuration(this@StoreViewModel)) + ChatState.PREPARING -> setUiState(Preparing(this@StoreViewModel)) + ChatState.PREPARED -> onConnected() + else -> Ignored + } + } + + override fun onChatRuntimeException(exception: RuntimeChatException) = scope("onChatRuntimeException") { + warning("Chat SDK reported exception.", exception) + } + } + + companion object { + private const val TAG = "StoreViewModel" + } +} diff --git a/store/src/main/java/com/nice/cxonechat/sample/viewModel/UiState.kt b/store/src/main/java/com/nice/cxonechat/sample/viewModel/UiState.kt new file mode 100644 index 00000000..1ecb7e86 --- /dev/null +++ b/store/src/main/java/com/nice/cxonechat/sample/viewModel/UiState.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2021-2023. 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.sample.viewModel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.nice.cxonechat.sample.R.string +import com.nice.cxonechat.sample.data.repository.UISettings +import com.nice.cxonechat.sample.ui.LoginDialog +import com.nice.cxonechat.sample.ui.SdkConfigurationDialog +import com.nice.cxonechat.sample.ui.uisettings.UISettingsDialog +import com.nice.cxonechat.ui.composable.theme.BusySpinner + +/** + * Current state of the UI. + * + * @property isInDialog true iff this state results in displaying a dialog. + */ +sealed class UiState private constructor(val isInDialog: Boolean) { + /** + * Execution context provided by the host activity. + */ + interface UiStateContext { + /** Invoke to login with the amazon OAuth provider. */ + fun loginWithAmazon() + + /** Invoke to pick an image to be used as a logo in the chat windows. */ + fun pickImage(onPickImage: (String?) -> Unit) + } + + /** + * Present any composable content, i.e., dialog, associated with this state. + * + * @param context Context with appropriate hooks back to host activity. + */ + @Composable + open fun Content(context: UiStateContext) { + } + + /** Nothing has been done yet. */ + data object Initial : UiState(isInDialog = false) { + @Composable + override fun Content(context: UiStateContext) { + BusySpinner(message = stringResource(string.loading)) + } + } + + /** Requesting Configuration details from the user. */ + data class Configuration(private val viewModel: StoreViewModel) : UiState(isInDialog = true) { + @Composable + override fun Content(context: UiStateContext) { + val settings = viewModel.chatSettingsRepository.settings.collectAsState() + val configuration = remember { derivedStateOf { settings.value?.sdkConfiguration } } + val configurations = viewModel.sdkConfigurationListRepository.configurationList.collectAsState() + + SdkConfigurationDialog( + configuration.value, + configurations.value, + { viewModel.setUiState(Prepared) }, + viewModel.chatSettingsHandler::setConfiguration + ) + } + } + + /** Preparing the chat object. */ + data class Preparing(private val viewModel: StoreViewModel) : UiState(isInDialog = true) { + @Composable + override fun Content(context: UiStateContext) { + BusySpinner( + message = stringResource(string.connecting), + onCancel = viewModel.chatProvider::cancel + ) + } + } + + /** Performing OAuth authentication with the user. */ + data object OAuth : UiState(isInDialog = true) { + @Composable + override fun Content(context: UiStateContext) { + context.loginWithAmazon() + } + } + + /** Performing simple authentication with the user. */ + data class Login( + private val viewModel: StoreViewModel, + ) : UiState(isInDialog = true) { + @Composable + override fun Content( + context: UiStateContext, + ) { + val settings = viewModel.chatSettingsRepository.settings.collectAsState() + val userName = remember { derivedStateOf { settings.value?.userName } } + + LoginDialog( + userName = userName.value, + onAccept = viewModel::setUserName, + ) { + viewModel.analyticsHandler.SendPageView("login", "/login") + } + } + } + + /** Displaying the UI Settings dialog. */ + data class UiSettings(private val viewModel: StoreViewModel) : UiState(isInDialog = true) { + @Composable + override fun Content(context: UiStateContext) { + UISettingsDialog( + value = UISettings.collectAsState().value, + onDismiss = { viewModel.setUiState(Prepared) }, + pickImage = context::pickImage, + onReset = viewModel.uiSettingsRepository::clear, + onConfirm = viewModel.uiSettingsRepository::save + ) + } + } + + /** Connection is prepared and ready for analytics. */ + data object Prepared : UiState(isInDialog = false) +} diff --git a/store/src/main/res/drawable/ic_chat_24px.xml b/store/src/main/res/drawable/ic_chat_24px.xml index d939016b..697a696f 100644 --- a/store/src/main/res/drawable/ic_chat_24px.xml +++ b/store/src/main/res/drawable/ic_chat_24px.xml @@ -1,16 +1,6 @@ <!-- - ~ Copyright (c) 2021-2023. 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. + ~ Apache license + ~ Material Symbols are available under the Apache License Version 2.0 --> <vector xmlns:android="http://schemas.android.com/apk/res/android" diff --git a/store/src/main/res/drawable/star.xml b/store/src/main/res/drawable/star.xml index a9e94aed..750070c9 100644 --- a/store/src/main/res/drawable/star.xml +++ b/store/src/main/res/drawable/star.xml @@ -1,16 +1,6 @@ <!-- - ~ Copyright (c) 2021-2023. 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. + ~ Apache license + ~ Material Symbols are available under the Apache License Version 2.0 --> <vector xmlns:android="http://schemas.android.com/apk/res/android" diff --git a/store/src/main/res/drawable/star_half.xml b/store/src/main/res/drawable/star_half.xml index 3e029392..363fb2ec 100644 --- a/store/src/main/res/drawable/star_half.xml +++ b/store/src/main/res/drawable/star_half.xml @@ -1,16 +1,6 @@ <!-- - ~ Copyright (c) 2021-2023. 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. + ~ Apache license + ~ Material Symbols are available under the Apache License Version 2.0 --> <vector xmlns:android="http://schemas.android.com/apk/res/android" diff --git a/store/src/main/res/drawable/star_outline.xml b/store/src/main/res/drawable/star_outline.xml index c4f815c8..a6f5915d 100644 --- a/store/src/main/res/drawable/star_outline.xml +++ b/store/src/main/res/drawable/star_outline.xml @@ -1,16 +1,6 @@ <!-- - ~ Copyright (c) 2021-2023. 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. + ~ Apache license + ~ Material Symbols are available under the Apache License Version 2.0 --> <vector xmlns:android="http://schemas.android.com/apk/res/android" 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 556936dd..8773c44a 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 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/> -</adaptive-icon> +</adaptive-icon> \ 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 556936dd..8773c44a 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 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/> -</adaptive-icon> +</adaptive-icon> \ No newline at end of file diff --git a/store/src/main/res/values/colors.xml b/store/src/main/res/values/colors.xml deleted file mode 100644 index 76305a67..00000000 --- a/store/src/main/res/values/colors.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright (c) 2021-2023. 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. - --> - -<resources> - <color name="purple_200">#FFBB86FC</color> - <color name="purple_500">#FF6200EE</color> - <color name="purple_700">#FF3700B3</color> - <color name="teal_200">#FF03DAC5</color> - <color name="black">#FF000000</color> - <color name="white">#FFFFFFFF</color> -</resources> diff --git a/store/src/main/res/values/strings.xml b/store/src/main/res/values/strings.xml index 2ee33c8b..fccad5fd 100644 --- a/store/src/main/res/values/strings.xml +++ b/store/src/main/res/values/strings.xml @@ -36,7 +36,6 @@ <!-- Validation Error Messages --> <string name="error_required_field">Required</string> <string name="error_email_validation">Invalid E-mail Address"</string> - <string name="error_value_validation">Invalid Value</string> <string name="error_validation_label">%1$s - %2$s</string> <string name="error_invalid_number">Invalid Number</string> <string name="error_invalid_integer">Invalid Integer</string> @@ -67,12 +66,12 @@ <string name="sdk_configuration">SDK Configuration</string> <string name="login">Login</string> <string name="sdk_settings">SDK Settings</string> - <string name="loading">Loading...</string> - <string name="connecting">Connecting...</string> + <string name="loading">Loading…</string> + <string name="connecting">Connecting…</string> <string name="logout">Logout</string> <string name="name">Name</string> <string name="card_number">Card Number</string> - <string name="card_number_placeholder">0000-0000-0000-0000</string> + <string name="card_number_placeholder">0000–0000–0000–0000</string> <string name="retry">Retry</string> <string name="unknown_product">Unknown Product</string> <string name="configuration">Configuration</string> @@ -80,4 +79,7 @@ <string name="set_defaults">Set Defaults</string> <string name="default_version_name">Preview</string> <string name="pick_a_logo_image">Pick a logo image</string> + + <string name="label_chat_permission">Open chat view</string> + <string name="desc_chat_permission">Allows the app to open the chat view.</string> </resources> diff --git a/store/src/main/res/values/themes.xml b/store/src/main/res/values/themes.xml index 10a5a6eb..a571f401 100644 --- a/store/src/main/res/values/themes.xml +++ b/store/src/main/res/values/themes.xml @@ -16,4 +16,4 @@ <resources> <style name="StoreFrontTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar" /> -</resources> +</resources> \ No newline at end of file diff --git a/store/src/main/res/xml/backup_rules.xml b/store/src/main/res/xml/backup_rules.xml index c1f53302..58b29d03 100644 --- a/store/src/main/res/xml/backup_rules.xml +++ b/store/src/main/res/xml/backup_rules.xml @@ -25,4 +25,4 @@ <include domain="sharedpref" path="."/> <exclude domain="sharedpref" path="device.xml"/> --> -</full-backup-content> +</full-backup-content> \ No newline at end of file diff --git a/store/src/main/res/xml/data_extraction_rules.xml b/store/src/main/res/xml/data_extraction_rules.xml index 1e3f1360..d26d42ac 100644 --- a/store/src/main/res/xml/data_extraction_rules.xml +++ b/store/src/main/res/xml/data_extraction_rules.xml @@ -31,4 +31,4 @@ <exclude .../> </device-transfer> --> -</data-extraction-rules> +</data-extraction-rules> \ No newline at end of file diff --git a/utilities/build.gradle b/utilities/build.gradle new file mode 100644 index 00000000..df2d61ec --- /dev/null +++ b/utilities/build.gradle @@ -0,0 +1,50 @@ +plugins { + id "android-library-conventions" + id "android-kotlin-conventions" + id "android-docs-conventions" + id "android-test-conventions" + id "android-library-style-conventions" + id "publish-conventions" +} + +android { + namespace 'com.nice.cxonechat.okhttp' + + defaultConfig { + consumerProguardFiles "consumer-rules.pro" + versionName version + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + } + } + + sourceSets { + main.java.srcDirs += "../utilities/src/main/java" + test { + resources { + srcDirs "src/test/assets" + } + } + } +} + +// Setup publishing of all library variants. +// Dependant will get matching variant automatically (eg.: buildType:debug will get buildType:debug) +// Alternatively they can provide transformation mapping. +mavenPublishing { + configure(new com.vanniktech.maven.publish.AndroidMultiVariantLibrary(true, true)) +} + +dependencies { + implementation "androidx.core:core-ktx:1.12.0" + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' +} diff --git a/utilities/lint-baseline.xml b/utilities/lint-baseline.xml new file mode 100644 index 00000000..f32fed49 --- /dev/null +++ b/utilities/lint-baseline.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<issues format="6" by="lint 8.1.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.2)" variant="all" version="8.1.2"> + +</issues> diff --git a/utilities/src/main/java/com/nice/cxonechat/utilities/DelegatingSocketFactory.kt b/utilities/src/main/java/com/nice/cxonechat/utilities/DelegatingSocketFactory.kt new file mode 100644 index 00000000..3953ed9f --- /dev/null +++ b/utilities/src/main/java/com/nice/cxonechat/utilities/DelegatingSocketFactory.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2021-2023. 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.utilities + +import java.net.InetAddress +import java.net.Socket +import javax.net.SocketFactory + +/** + * A javax.net.SocketFactory that delegates all creation requests to [origin] and then gives + * the actual implementation a chance to configure the created socket. + * + * @property origin Base [SocketFactory] to which all creation requests are delegated. + */ +abstract class DelegatingSocketFactory( + val origin: SocketFactory = getDefault() +) : SocketFactory() { + override fun createSocket() = origin.createSocket().also(::configure) + + override fun createSocket(host: String?, port: Int) = + origin.createSocket(host, port).also(::configure) + + override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int) = + origin.createSocket(host, port, localHost, localPort).also(::configure) + + override fun createSocket(host: InetAddress?, port: Int) = + origin.createSocket(host, port).also(::configure) + + override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int) = + origin.createSocket(address, port, localAddress, localPort).also(::configure) + + /** + * Configure a socket after it is created by [origin] and before it is returned + * by [create]. + * + * @param socket the socket to be configured. + */ + abstract fun configure(socket: Socket) +} diff --git a/utilities/src/main/java/com/nice/cxonechat/utilities/IterableExt.kt b/utilities/src/main/java/com/nice/cxonechat/utilities/IterableExt.kt new file mode 100644 index 00000000..45832171 --- /dev/null +++ b/utilities/src/main/java/com/nice/cxonechat/utilities/IterableExt.kt @@ -0,0 +1,24 @@ +/* + * 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.utilities + +/** + * Returns true iff the receiver contains zero items. + * + * @receiver [Iterable] to test. + * @return true iff the receiver contains zero items. + */ +fun <T> Iterable<T>.isEmpty() = firstOrNull() == null diff --git a/utilities/src/main/java/com/nice/cxonechat/utilities/TaggingSocketFactory.kt b/utilities/src/main/java/com/nice/cxonechat/utilities/TaggingSocketFactory.kt new file mode 100644 index 00000000..5ca206d5 --- /dev/null +++ b/utilities/src/main/java/com/nice/cxonechat/utilities/TaggingSocketFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2021-2023. 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.utilities + +import android.net.TrafficStats +import android.os.Build +import android.system.Os +import java.net.Socket + +/** + * SocketFactory to tag sockets per [TrafficStats]. + * + * All created sockets will be tagged with the current thread id, and if + * running on an SDK >= P, with the process UID. + */ +object TaggingSocketFactory : DelegatingSocketFactory() { + override fun configure(socket: Socket) { + val socketTag = Thread.currentThread().id.toInt() + if (TrafficStats.getThreadStatsTag() != socketTag) { + TrafficStats.setThreadStatsTag(socketTag) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + TrafficStats.setThreadStatsUid(Os.getuid()) + } + TrafficStats.tagSocket(socket) + } +}