diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 114cf0f79..d4e372a94 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - module: [sdk, messagingpush] # android modules + module: [sdk, messagingpush, messaginginapp] # android modules name: Android Lint (${{ matrix.module }}) steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a674fac9..fc510e5a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - module: [sdk, messagingpush, base] + module: [sdk, messagingpush, messaginginapp, base] name: Unit tests (${{ matrix.module }}) steps: - uses: actions/checkout@v2 diff --git a/app/src/androidTest/java/io/customer/example/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/customer/example/ExampleInstrumentedTest.kt deleted file mode 100644 index 55918e33d..000000000 --- a/app/src/androidTest/java/io/customer/example/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.customer.example - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.customer.example", appContext.packageName) - } -} diff --git a/base/src/main/java/io/customer/base/extenstions/CoroutineExtensions.kt b/base/src/main/java/io/customer/base/extenstions/CoroutineExtensions.kt deleted file mode 100644 index 57df1745c..000000000 --- a/base/src/main/java/io/customer/base/extenstions/CoroutineExtensions.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.customer.base.extenstions - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* - -fun Flow.flatMapLatestNullable(transform: suspend (value: T) -> Flow): Flow { - return flatMapLatest { if (it != null) transform(it) else flowOf(null) } -} - -fun Flow.mapNullable(transform: suspend (value: T) -> R): Flow { - return map { if (it != null) transform(it) else null } -} - -fun delayFlow(timeout: Long, value: T): Flow = flow { - delay(timeout) - emit(value) -} diff --git a/build.gradle b/build.gradle index 488ac100d..7eac34224 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,10 @@ allprojects { repositories { google() mavenCentral() + // added for gist SDK + maven { + url 'https://maven.gist.build' + } } } diff --git a/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt b/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt index 226842d22..777520cc6 100644 --- a/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt +++ b/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt @@ -22,6 +22,7 @@ object Dependencies { const val dokka = "org.jetbrains.dokka:dokka-gradle-plugin:${Versions.DOKKA}" const val firebaseMessaging = "com.google.firebase:firebase-messaging:${Versions.FIREBASE_MESSAGING}" + const val gist = "build:gist:${Versions.GIST}" const val googlePlayServicesBase = "com.google.android.gms:play-services-base:${Versions.GOOGLE_PLAY_SERVICES_BASE}" const val gradleNexusPublishPlugin = diff --git a/buildSrc/src/main/kotlin/io.customer/android/Versions.kt b/buildSrc/src/main/kotlin/io.customer/android/Versions.kt index 6a3623183..d8cf43799 100644 --- a/buildSrc/src/main/kotlin/io.customer/android/Versions.kt +++ b/buildSrc/src/main/kotlin/io.customer/android/Versions.kt @@ -1,7 +1,7 @@ package io.customer.android object Versions { - internal const val ANDROID_GRADLE_PLUGIN = "7.0.3" + internal const val ANDROID_GRADLE_PLUGIN = "7.2.0" internal const val ANDROIDX_TEST_JUNIT = "1.1.3" internal const val ANDROIDX_TEST_RUNNER = "1.4.0" internal const val ANDROIDX_TEST_RULES = "1.4.0" @@ -15,13 +15,14 @@ object Versions { internal const val FIREBASE_MESSAGING = "22.0.0" internal const val GRADLE_NEXUS_PUBLISH_PLUGIN = "1.1.0" internal const val GRADLE_VERSIONS_PLUGIN = "0.39.0" + internal const val GIST = "2.1.3" internal const val GOOGLE_PLAY_SERVICES_BASE = "17.6.0" internal const val KLUENT = "1.68" internal const val KOTLIN = "1.5.31" internal const val MATERIAL_COMPONENTS = "1.4.0" internal const val MOCKITO_KOTLIN = "4.0.0" internal const val MOCKITO = "3.11.2" - internal const val MOSHI = "1.12.0" + internal const val MOSHI = "1.13.0" internal const val TIMBER = "5.0.0" internal const val ROBOLECTRIC = "4.6.1" internal const val OKHTTP = "4.9.1" diff --git a/common-test/src/main/java/io/customer/commontest/BaseTest.kt b/common-test/src/main/java/io/customer/commontest/BaseTest.kt index 25d57417a..59f0f4706 100644 --- a/common-test/src/main/java/io/customer/commontest/BaseTest.kt +++ b/common-test/src/main/java/io/customer/commontest/BaseTest.kt @@ -66,7 +66,7 @@ abstract class BaseTest { di = CustomerIOComponent( sdkConfig = cioConfig, - context = this@BaseTest.context + context = application ) di.fileStorage.deleteAllSdkFiles() di.sharedPreferenceRepository.clearAll() diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f4ccfd552..f58814402 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Apr 01 11:17:23 CDT 2021 +#Wed May 11 20:51:08 PKT 2022 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +zipStoreBase=GRADLE_USER_HOME diff --git a/messaginginapp/.gitignore b/messaginginapp/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/messaginginapp/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/messaginginapp/build.gradle b/messaginginapp/build.gradle new file mode 100644 index 000000000..969588218 --- /dev/null +++ b/messaginginapp/build.gradle @@ -0,0 +1,52 @@ +import io.customer.android.Configurations +import io.customer.android.Dependencies + +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +ext { + PUBLISH_GROUP_ID = Configurations.artifactGroup + PUBLISH_ARTIFACT_ID = "messaging-in-app" +} + +apply from: "${rootDir}/scripts/publish-module.gradle" +apply from: "${rootDir}/scripts/android-config.gradle" +apply from: "${rootDir}/scripts/codecov-android.gradle" +apply from: "${rootDir}/scripts/android-module-testing.gradle" + +android { + defaultConfig { + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + api project(":base") + api project(":sdk") + + implementation Dependencies.coroutinesCore + implementation Dependencies.coroutinesAndroid + implementation Dependencies.retrofit + api Dependencies.gist + implementation Dependencies.retrofitMoshiConverter + implementation Dependencies.okhttpLoggingInterceptor + testImplementation Dependencies.androidxTestJunit + androidTestImplementation Dependencies.junit4 + +} \ No newline at end of file diff --git a/messaginginapp/consumer-rules.pro b/messaginginapp/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/messaginginapp/proguard-rules.pro b/messaginginapp/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/messaginginapp/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/messaginginapp/src/main/AndroidManifest.xml b/messaginginapp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..3cfd7da9c --- /dev/null +++ b/messaginginapp/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt new file mode 100644 index 000000000..24ec91b03 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt @@ -0,0 +1,80 @@ +package io.customer.messaginginapp + +import android.app.Application +import io.customer.messaginginapp.di.gistProvider +import io.customer.messaginginapp.hook.ModuleInAppHookProvider +import io.customer.sdk.CustomerIO +import io.customer.sdk.CustomerIOModule +import io.customer.sdk.data.request.MetricEvent +import io.customer.sdk.di.CustomerIOComponent +import io.customer.sdk.hooks.HookModule +import io.customer.sdk.hooks.HooksManager +import io.customer.sdk.repository.TrackRepository + +class ModuleMessagingInApp internal constructor( + private val overrideDiGraph: CustomerIOComponent?, + private val organizationId: String +) : CustomerIOModule { + + constructor(organizationId: String) : this( + overrideDiGraph = null, + organizationId = organizationId + ) + + override val moduleName: String + get() = "MessagingInApp" + + private val diGraph: CustomerIOComponent + get() = overrideDiGraph ?: CustomerIO.instance().diGraph + + private val trackRepository: TrackRepository + get() = diGraph.trackRepository + + private val hooksManager: HooksManager by lazy { diGraph.hooksManager } + + private val gistProvider by lazy { diGraph.gistProvider } + + private val logger by lazy { diGraph.logger } + + override fun initialize() { + initializeGist(organizationId) + setupHooks() + setupGistCallbacks() + } + + private fun setupGistCallbacks() { + gistProvider.subscribeToEvents( + onMessageShown = { deliveryID -> + logger.debug("in-app message shown $deliveryID") + trackRepository.trackInAppMetric( + deliveryID = deliveryID, + event = MetricEvent.opened + ) + }, + onAction = { deliveryID: String, _: String, _: String -> + logger.debug("in-app message clicked $deliveryID") + trackRepository.trackInAppMetric( + deliveryID = deliveryID, + event = MetricEvent.clicked + ) + }, + onError = { errorMessage -> + logger.error("in-app message error occurred $errorMessage") + } + ) + } + + private fun setupHooks() { + hooksManager.add( + module = HookModule.MessagingInApp, + subscriber = ModuleInAppHookProvider() + ) + } + + private fun initializeGist(organizationId: String) { + gistProvider.initProvider( + application = diGraph.context as Application, + organizationId = organizationId + ) + } +} diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessaginIApp.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessaginIApp.kt new file mode 100644 index 000000000..9f32cb026 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessaginIApp.kt @@ -0,0 +1,13 @@ +package io.customer.messaginginapp.di + +import io.customer.messaginginapp.provider.GistApi +import io.customer.messaginginapp.provider.GistApiProvider +import io.customer.messaginginapp.provider.GistInAppMessagesProvider +import io.customer.messaginginapp.provider.InAppMessagesProvider +import io.customer.sdk.di.CustomerIOComponent + +internal val CustomerIOComponent.gistApiProvider: GistApi + get() = override() ?: GistApiProvider() + +internal val CustomerIOComponent.gistProvider: InAppMessagesProvider + get() = override() ?: GistInAppMessagesProvider(gistApiProvider) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/hook/ModuleInAppHookProvider.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/hook/ModuleInAppHookProvider.kt new file mode 100644 index 000000000..b917935b3 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/hook/ModuleInAppHookProvider.kt @@ -0,0 +1,29 @@ +package io.customer.messaginginapp.hook + +import io.customer.messaginginapp.di.gistProvider +import io.customer.messaginginapp.provider.InAppMessagesProvider +import io.customer.sdk.CustomerIO +import io.customer.sdk.di.CustomerIOComponent +import io.customer.sdk.hooks.ModuleHook +import io.customer.sdk.hooks.ModuleHookProvider + +class ModuleInAppHookProvider : ModuleHookProvider() { + + private val diGraph: CustomerIOComponent + get() = CustomerIO.instance().diGraph + + private val gistProvider: InAppMessagesProvider + get() = diGraph.gistProvider + + override fun profileIdentifiedHook(hook: ModuleHook.ProfileIdentifiedHook) { + gistProvider.setUserToken(hook.identifier) + } + + override fun screenTrackedHook(hook: ModuleHook.ScreenTrackedHook) { + gistProvider.setCurrentRoute(hook.screen) + } + + override fun beforeProfileStoppedBeingIdentified(hook: ModuleHook.BeforeProfileStoppedBeingIdentified) { + gistProvider.clearUserToken() + } +} diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/provider/GistApiProvider.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/provider/GistApiProvider.kt new file mode 100644 index 000000000..17f7fd0dd --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/provider/GistApiProvider.kt @@ -0,0 +1,71 @@ +package io.customer.messaginginapp.provider + +import android.app.Application +import build.gist.data.model.GistMessageProperties +import build.gist.data.model.Message +import build.gist.presentation.GistListener +import build.gist.presentation.GistSdk + +/** + * Wrapper around Gist Apis + */ +internal interface GistApi { + fun initProvider(application: Application, organizationId: String) + fun setUserToken(userToken: String) + fun setCurrentRoute(route: String) + fun clearUserToken() + fun subscribeToEvents( + onMessageShown: (deliveryId: String) -> Unit, + onAction: (deliveryId: String?, currentRoute: String, action: String) -> Unit, + onError: (errorMessage: String) -> Unit + ) +} + +internal class GistApiProvider : GistApi { + override fun initProvider(application: Application, organizationId: String) { + GistSdk.init( + application = application, + organizationId = organizationId + ) + } + + override fun setUserToken(userToken: String) { + GistSdk.setUserToken(userToken) + } + + override fun setCurrentRoute(route: String) { + GistSdk.setCurrentRoute(route) + } + + override fun clearUserToken() { + GistSdk.clearUserToken() + } + + override fun subscribeToEvents( + onMessageShown: (String) -> Unit, + onAction: (deliveryId: String?, currentRoute: String, action: String) -> Unit, + onError: (message: String) -> Unit + ) { + GistSdk.addListener(object : GistListener { + override fun embedMessage(message: Message, elementId: String) { + } + + override fun onAction(message: Message, currentRoute: String, action: String) { + val deliveryID = GistMessageProperties.getGistProperties(message).campaignId + onAction(deliveryID, currentRoute, action) + } + + override fun onError(message: Message) { + onError(message.toString()) + } + + override fun onMessageDismissed(message: Message) { + } + + override fun onMessageShown(message: Message) { + val deliveryID = GistMessageProperties.getGistProperties(message).campaignId + deliveryID?.let { onMessageShown(it) } + } + }) + } +} diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/provider/GistInAppMessagesProvider.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/provider/GistInAppMessagesProvider.kt new file mode 100644 index 000000000..46313e993 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/provider/GistInAppMessagesProvider.kt @@ -0,0 +1,58 @@ +package io.customer.messaginginapp.provider + +import android.app.Application + +internal interface InAppMessagesProvider { + fun initProvider(application: Application, organizationId: String) + fun setUserToken(userToken: String) + fun setCurrentRoute(route: String) + fun clearUserToken() + fun subscribeToEvents( + onMessageShown: (deliveryId: String) -> Unit, + onAction: (deliveryId: String, currentRoute: String, action: String) -> Unit, + onError: (errorMessage: String) -> Unit + ) +} + +/** + * Wrapper around Gist SDK + */ +internal class GistInAppMessagesProvider(private val provider: GistApi) : + InAppMessagesProvider { + + override fun initProvider(application: Application, organizationId: String) { + provider.initProvider(application, organizationId) + } + + override fun setUserToken(userToken: String) { + provider.setUserToken(userToken) + } + + override fun setCurrentRoute(route: String) { + provider.setCurrentRoute(route) + } + + override fun clearUserToken() { + provider.clearUserToken() + } + + override fun subscribeToEvents( + onMessageShown: (String) -> Unit, + onAction: (deliveryId: String, currentRoute: String, action: String) -> Unit, + onError: (message: String) -> Unit + ) { + provider.subscribeToEvents( + onMessageShown = { deliveryID -> + onMessageShown(deliveryID) + }, + onAction = { deliveryID: String?, currentRoute: String, action: String -> + if (deliveryID != null && action != "gist://close") { + onAction(deliveryID, currentRoute, action) + } + }, + onError = { errorMessage -> + onError(errorMessage) + } + ) + } +} diff --git a/messaginginapp/src/sharedTest/java/io/customer/messaginginapp/InAppMessagesProviderTest.kt b/messaginginapp/src/sharedTest/java/io/customer/messaginginapp/InAppMessagesProviderTest.kt new file mode 100644 index 000000000..978dcd5f7 --- /dev/null +++ b/messaginginapp/src/sharedTest/java/io/customer/messaginginapp/InAppMessagesProviderTest.kt @@ -0,0 +1,150 @@ +package io.customer.messaginginapp + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.customer.commontest.BaseTest +import io.customer.messaginginapp.provider.GistApi +import io.customer.messaginginapp.provider.GistInAppMessagesProvider +import io.customer.messaginginapp.provider.InAppMessagesProvider +import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +internal class InAppMessagesProviderTest : BaseTest() { + + private lateinit var gistInAppMessagesProvider: InAppMessagesProvider + private val gistApiProvider: GistApi = mock() + + @Before + override fun setup() { + super.setup() + gistInAppMessagesProvider = GistInAppMessagesProvider(gistApiProvider) + } + + @Test + fun whenSubscribedToEvents_expectMessageShownWithDeliveryId() { + whenever(gistApiProvider.subscribeToEvents(any(), any(), any())).then { invocation -> + (invocation.arguments[0] as (deliveryId: String) -> Unit).invoke("test-deliveryId") + } + + var wasOnMessageShownCalled = false + var wasOnActionCalled = false + var wasOnErrorCalled = false + + gistInAppMessagesProvider.subscribeToEvents( + onMessageShown = { deliveryID -> + wasOnMessageShownCalled = true + assertEquals("test-deliveryId", deliveryID) + }, + onAction = { _: String?, _: String, _: String -> + wasOnActionCalled = true + }, + onError = { + wasOnErrorCalled = true + } + ) + + wasOnMessageShownCalled shouldBeEqualTo true + wasOnActionCalled shouldBeEqualTo false + wasOnErrorCalled shouldBeEqualTo false + } + + @Test + fun whenSubscribedToEvents_expectOnActionWithDeliveryIdCurrentRouteAndAction() { + whenever(gistApiProvider.subscribeToEvents(any(), any(), any())).then { invocation -> + (invocation.arguments[1] as (deliveryId: String, currentRoute: String, action: String) -> Unit).invoke( + "test-deliveryId", + "test-route", + "test-action" + ) + } + + var wasOnMessageShownCalled = false + var wasOnActionCalled = false + var wasOnErrorCalled = false + + gistInAppMessagesProvider.subscribeToEvents( + onMessageShown = { + wasOnMessageShownCalled = true + }, + onAction = { deliveryID: String?, currentRoute: String, action: String -> + wasOnActionCalled = true + assertEquals("test-deliveryId", deliveryID) + assertEquals("test-route", currentRoute) + assertEquals("test-action", action) + }, + onError = { + wasOnErrorCalled = true + } + ) + + wasOnMessageShownCalled shouldBeEqualTo false + wasOnActionCalled shouldBeEqualTo true + wasOnErrorCalled shouldBeEqualTo false + } + + @Test + fun whenSubscribedToEvents_expectOnActionWithCloseAction_expectOnActionCallbackToBeIgnored() { + whenever(gistApiProvider.subscribeToEvents(any(), any(), any())).then { invocation -> + (invocation.arguments[1] as (deliveryId: String, currentRoute: String, action: String) -> Unit).invoke( + "test-deliveryId", + "test-route", + "gist://close" + ) + } + + var wasOnMessageShownCalled = false + var wasOnActionCalled = false + var wasOnErrorCalled = false + + gistInAppMessagesProvider.subscribeToEvents( + onMessageShown = { + wasOnMessageShownCalled = true + }, + onAction = { _: String?, _: String, _: String -> + wasOnActionCalled = true + }, + onError = { + wasOnErrorCalled = true + } + ) + + // no event gets called + wasOnMessageShownCalled shouldBeEqualTo false + wasOnActionCalled shouldBeEqualTo false + wasOnErrorCalled shouldBeEqualTo false + } + + @Test + fun whenSubscribedToEvents_expectOnError() { + whenever(gistApiProvider.subscribeToEvents(any(), any(), any())).then { invocation -> + (invocation.arguments[2] as (errorMessage: String) -> Unit).invoke("test-error-message") + } + + var wasOnMessageShownCalled = false + var wasOnActionCalled = false + var wasOnErrorCalled = false + + gistInAppMessagesProvider.subscribeToEvents( + onMessageShown = { deliveryID -> + wasOnMessageShownCalled = true + }, + onAction = { _: String?, _: String, _: String -> + wasOnActionCalled = true + }, + onError = { errorMessage -> + wasOnErrorCalled = true + assertEquals("test-error-message", errorMessage) + } + ) + + wasOnMessageShownCalled shouldBeEqualTo false + wasOnActionCalled shouldBeEqualTo false + wasOnErrorCalled shouldBeEqualTo true + } +} diff --git a/messaginginapp/src/sharedTest/java/io/customer/messaginginapp/ModuleMessagingInAppTest.kt b/messaginginapp/src/sharedTest/java/io/customer/messaginginapp/ModuleMessagingInAppTest.kt new file mode 100644 index 000000000..ef74dada4 --- /dev/null +++ b/messaginginapp/src/sharedTest/java/io/customer/messaginginapp/ModuleMessagingInAppTest.kt @@ -0,0 +1,46 @@ +package io.customer.messaginginapp + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.customer.commontest.BaseTest +import io.customer.messaginginapp.provider.InAppMessagesProvider +import io.customer.sdk.hooks.HookModule +import io.customer.sdk.hooks.HooksManager +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +internal class ModuleMessagingInAppTest : BaseTest() { + + private lateinit var module: ModuleMessagingInApp + private val gistInAppMessagesProvider: InAppMessagesProvider = mock() + private val hooksManager: HooksManager = mock() + + @Before + override fun setup() { + super.setup() + + di.overrideDependency(InAppMessagesProvider::class.java, gistInAppMessagesProvider) + di.overrideDependency(HooksManager::class.java, hooksManager) + + module = ModuleMessagingInApp(overrideDiGraph = di, organizationId = "test") + } + + @Test + fun initialize_givenComponentInitialize_expectGistToInitializeWithCorrectOrganizationId_expectModuleHookToBeAdded_expectSubscriptionOfGistCallbacks() { + module.initialize() + + // verify gist is initialized + verify(gistInAppMessagesProvider).initProvider(any(), eq("test")) + + // verify hook was added + verify(hooksManager).add(eq(HookModule.MessagingInApp), any()) + + // verify events + verify(gistInAppMessagesProvider).subscribeToEvents(any(), any(), any()) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOFirebaseMessagingService.kt b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOFirebaseMessagingService.kt index c95dd935d..27ae2a3d7 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOFirebaseMessagingService.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOFirebaseMessagingService.kt @@ -21,7 +21,6 @@ class CustomerIOFirebaseMessagingService : FirebaseMessagingService() { * @param remoteMessage Remote message received from Firebase in * [FirebaseMessagingService.onMessageReceived] * @param handleNotificationTrigger indicating if the local notification should be triggered - * @param errorCallback callback containing any error occurred * @return Boolean indicating whether this will be handled by CustomerIo */ @JvmOverloads @@ -37,7 +36,6 @@ class CustomerIOFirebaseMessagingService : FirebaseMessagingService() { * Call this from [FirebaseMessagingService] to register the new device token * * @param token new or refreshed token - * @param errorCallback callback containing any error occurred */ @JvmOverloads fun onNewToken( diff --git a/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt b/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt index 4cf2b36b1..971f50796 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt @@ -17,7 +17,6 @@ class ModuleMessagingPushFCM internal constructor( get() = overrideCustomerIO ?: CustomerIO.instance() private val diGraph: CustomerIOComponent get() = overrideDiGraph ?: CustomerIO.instance().diGraph - private val fcmTokenProvider by lazy { diGraph.fcmTokenProvider } override val moduleName: String @@ -36,7 +35,7 @@ class ModuleMessagingPushFCM internal constructor( */ private fun getCurrentFcmToken() { fcmTokenProvider.getCurrentToken { token -> - token?.let { token -> customerIO.registerDeviceToken(token) } + token?.let { customerIO.registerDeviceToken(token) } } } } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt b/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt index a4a5498f3..6afbe2e10 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt @@ -1,7 +1,7 @@ package io.customer.messagingpush.di -import io.customer.messagingpush.provider.FCMTokenProvider import io.customer.messagingpush.provider.FCMTokenProviderImpl +import io.customer.sdk.device.DeviceTokenProvider import io.customer.sdk.di.CustomerIOComponent /* @@ -10,5 +10,5 @@ This file contains a series of extensions to the common module's Dependency inje The use of extensions was chosen over creating a separate graph class for each module. This simplifies the SDK code as well as automated tests code dramatically. */ -internal val CustomerIOComponent.fcmTokenProvider: FCMTokenProvider - get() = override() ?: FCMTokenProviderImpl(logger) +internal val CustomerIOComponent.fcmTokenProvider: DeviceTokenProvider + get() = override() ?: FCMTokenProviderImpl(logger = logger, context = context) diff --git a/messagingpush/src/main/java/io/customer/messagingpush/provider/FCMTokenProvider.kt b/messagingpush/src/main/java/io/customer/messagingpush/provider/FCMTokenProvider.kt deleted file mode 100644 index 52641fc7f..000000000 --- a/messagingpush/src/main/java/io/customer/messagingpush/provider/FCMTokenProvider.kt +++ /dev/null @@ -1,34 +0,0 @@ -package io.customer.messagingpush.provider - -import com.google.firebase.messaging.FirebaseMessaging -import io.customer.sdk.util.Logger - -/** - * Wrapper around FCM SDK to make the code base more testable. - */ -internal interface FCMTokenProvider { - fun getCurrentToken(onComplete: (String?) -> Unit) -} - -/** - * This class should be as small as possible as possible because it can't be tested with automated tests. QA testing, only. - */ -class FCMTokenProviderImpl(private val logger: Logger) : FCMTokenProvider { - - override fun getCurrentToken(onComplete: (String?) -> Unit) { - logger.debug("getting current FCM device token...") - - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (task.isSuccessful) { - val existingDeviceToken = task.result - logger.debug("got current FCM token: $existingDeviceToken") - - onComplete(existingDeviceToken) - } else { - logger.debug("got current FCM token: null") - - onComplete(null) - } - } - } -} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/provider/FCMTokenProviderImpl.kt b/messagingpush/src/main/java/io/customer/messagingpush/provider/FCMTokenProviderImpl.kt new file mode 100644 index 000000000..64371b430 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/provider/FCMTokenProviderImpl.kt @@ -0,0 +1,58 @@ +package io.customer.messagingpush.provider + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.firebase.messaging.FirebaseMessaging +import io.customer.sdk.device.DeviceTokenProvider +import io.customer.sdk.util.Logger + +/** + * Wrapper around FCM SDK to make the code base more testable. There is no concept of checked-exceptions in Kotlin + * so we need to handle the exception manually. + */ +class FCMTokenProviderImpl( + private val logger: Logger, + private val context: Context +) : DeviceTokenProvider { + + override fun isValidForThisDevice(context: Context): Boolean { + return try { + ( + GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + ).also { + logger.info("Is Firebase available on on this device -> $it") + } + } catch (exception: Throwable) { + logger.error(exception.message ?: "error checking google play services availability") + false + } + } + + override fun getCurrentToken(onComplete: (String?) -> Unit) { + logger.debug("getting current FCM device token...") + try { + if (!isValidForThisDevice(context)) { + onComplete(null) + return + } + + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (task.isSuccessful) { + val existingDeviceToken = task.result + logger.debug("got current FCM token: $existingDeviceToken") + + onComplete(existingDeviceToken) + } else { + logger.debug("got current FCM token: null") + logger.error(task.exception?.message ?: "error while getting FCM token") + onComplete(null) + } + } + } catch (exception: Throwable) { + logger.error(exception.message ?: "error while getting FCM token") + onComplete(null) + } + } +} diff --git a/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt b/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt index 0d3d579e8..4753dcd99 100644 --- a/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt +++ b/messagingpush/src/sharedTest/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt @@ -2,8 +2,8 @@ package io.customer.messagingpush import androidx.test.ext.junit.runners.AndroidJUnit4 import io.customer.commontest.BaseTest -import io.customer.messagingpush.provider.FCMTokenProvider import io.customer.sdk.CustomerIOInstance +import io.customer.sdk.device.DeviceTokenProvider import io.customer.sdk.utils.random import org.junit.Before import org.junit.Test @@ -19,14 +19,14 @@ import org.mockito.kotlin.whenever internal class ModuleMessagingPushFCMTest : BaseTest() { private val customerIOMock: CustomerIOInstance = mock() - private val fcmTokenProviderMock: FCMTokenProvider = mock() + private val fcmTokenProviderMock: DeviceTokenProvider = mock() private lateinit var module: ModuleMessagingPushFCM @Before override fun setup() { super.setup() - di.overrideDependency(FCMTokenProvider::class.java, fcmTokenProviderMock) + di.overrideDependency(DeviceTokenProvider::class.java, fcmTokenProviderMock) module = ModuleMessagingPushFCM(overrideCustomerIO = customerIOMock, overrideDiGraph = di) } diff --git a/sdk/src/androidTest/java/io/customer/sdk/util/SimpleTimerTest.kt b/sdk/src/androidTest/java/io/customer/sdk/util/SimpleTimerTest.kt index 6987462fa..a259e892e 100644 --- a/sdk/src/androidTest/java/io/customer/sdk/util/SimpleTimerTest.kt +++ b/sdk/src/androidTest/java/io/customer/sdk/util/SimpleTimerTest.kt @@ -22,7 +22,7 @@ class SimpleTimerTest : BaseTest() { override fun setup() { super.setup() - timer = AndroidSimpleTimer(di.logger) + timer = AndroidSimpleTimer(di.logger, testDispatcher) } @After diff --git a/sdk/src/main/java/io/customer/sdk/CustomerIOModule.kt b/sdk/src/main/java/io/customer/sdk/CustomerIOModule.kt index 146d21b27..86daec779 100644 --- a/sdk/src/main/java/io/customer/sdk/CustomerIOModule.kt +++ b/sdk/src/main/java/io/customer/sdk/CustomerIOModule.kt @@ -4,6 +4,7 @@ package io.customer.sdk * A module is optional Customer.io SDK that you can install in your app. * * This interface allows the base SDK to initialize all of the SDKs installed in an app and begin to communicate with them. + * It is recommended to keep the modules light as they are strongly referenced by *tracking* module */ interface CustomerIOModule { val moduleName: String diff --git a/sdk/src/main/java/io/customer/sdk/api/TrackingHttpClient.kt b/sdk/src/main/java/io/customer/sdk/api/TrackingHttpClient.kt index 852514aad..07d449c61 100644 --- a/sdk/src/main/java/io/customer/sdk/api/TrackingHttpClient.kt +++ b/sdk/src/main/java/io/customer/sdk/api/TrackingHttpClient.kt @@ -2,7 +2,8 @@ package io.customer.sdk.api import io.customer.sdk.api.service.CustomerIOService import io.customer.sdk.data.model.CustomAttributes -import io.customer.sdk.data.request.Device +import io.customer.sdk.data.request.* +import io.customer.sdk.data.request.DeliveryEvent import io.customer.sdk.data.request.DeviceRequest import io.customer.sdk.data.request.Event import io.customer.sdk.data.request.Metric @@ -16,6 +17,7 @@ internal interface TrackingHttpClient { suspend fun registerDevice(identifier: String, device: Device): Result suspend fun deleteDevice(identifier: String, deviceToken: String): Result suspend fun trackPushMetrics(metric: Metric): Result + suspend fun trackDeliveryEvents(event: DeliveryEvent): Result } internal class RetrofitTrackingHttpClient( @@ -23,7 +25,10 @@ internal class RetrofitTrackingHttpClient( private val httpRequestRunner: HttpRequestRunner ) : TrackingHttpClient { - override suspend fun identifyProfile(identifier: String, attributes: CustomAttributes): Result { + override suspend fun identifyProfile( + identifier: String, + attributes: CustomAttributes + ): Result { return httpRequestRunner.performAndProcessRequest { retrofitService.identifyCustomer(identifier, attributes) } @@ -52,4 +57,10 @@ internal class RetrofitTrackingHttpClient( retrofitService.trackMetric(metric) } } + + override suspend fun trackDeliveryEvents(event: DeliveryEvent): Result { + return httpRequestRunner.performAndProcessRequest { + retrofitService.trackDeliveryEvents(event) + } + } } diff --git a/sdk/src/main/java/io/customer/sdk/api/service/CustomerIOService.kt b/sdk/src/main/java/io/customer/sdk/api/service/CustomerIOService.kt index 22ba785b6..0114957bf 100644 --- a/sdk/src/main/java/io/customer/sdk/api/service/CustomerIOService.kt +++ b/sdk/src/main/java/io/customer/sdk/api/service/CustomerIOService.kt @@ -1,6 +1,7 @@ package io.customer.sdk.api.service import io.customer.sdk.data.model.CustomAttributes +import io.customer.sdk.data.request.DeliveryEvent import io.customer.sdk.data.request.DeviceRequest import io.customer.sdk.data.request.Event import io.customer.sdk.data.request.Metric @@ -29,6 +30,12 @@ internal interface CustomerIOService { @Body body: Metric ): Response + @JvmSuppressWildcards + @POST("api/v1/cio_deliveries/events") + suspend fun trackDeliveryEvents( + @Body body: DeliveryEvent + ): Response + @JvmSuppressWildcards @PUT("api/v1/customers/{identifier}/devices") suspend fun addDevice( diff --git a/sdk/src/main/java/io/customer/sdk/data/request/DeliveryEvent.kt b/sdk/src/main/java/io/customer/sdk/data/request/DeliveryEvent.kt new file mode 100644 index 000000000..997003d56 --- /dev/null +++ b/sdk/src/main/java/io/customer/sdk/data/request/DeliveryEvent.kt @@ -0,0 +1,23 @@ +package io.customer.sdk.data.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.util.* + +@JsonClass(generateAdapter = false) +internal enum class DeliveryType { + in_app +} + +@JsonClass(generateAdapter = true) +internal data class DeliveryPayload( + @field:Json(name = "delivery_id") val deliveryID: String, + val event: MetricEvent, + val timestamp: Date +) + +@JsonClass(generateAdapter = true) +internal data class DeliveryEvent( + val type: DeliveryType, + val payload: DeliveryPayload +) diff --git a/sdk/src/main/java/io/customer/sdk/data/request/Metric.kt b/sdk/src/main/java/io/customer/sdk/data/request/Metric.kt index cb9b429c5..99ed5a569 100644 --- a/sdk/src/main/java/io/customer/sdk/data/request/Metric.kt +++ b/sdk/src/main/java/io/customer/sdk/data/request/Metric.kt @@ -6,7 +6,7 @@ import java.util.* @JsonClass(generateAdapter = false) enum class MetricEvent { - delivered, opened, converted; + delivered, opened, converted, clicked; } @JsonClass(generateAdapter = true) diff --git a/sdk/src/main/java/io/customer/sdk/device/DeviceTokenProvider.kt b/sdk/src/main/java/io/customer/sdk/device/DeviceTokenProvider.kt new file mode 100644 index 000000000..090db9d47 --- /dev/null +++ b/sdk/src/main/java/io/customer/sdk/device/DeviceTokenProvider.kt @@ -0,0 +1,11 @@ +package io.customer.sdk.device + +import android.content.Context + +/** + * Responsible for token generation and validity + */ +interface DeviceTokenProvider { + fun isValidForThisDevice(context: Context): Boolean + fun getCurrentToken(onComplete: (String?) -> Unit) +} diff --git a/sdk/src/main/java/io/customer/sdk/di/CustomerIOComponent.kt b/sdk/src/main/java/io/customer/sdk/di/CustomerIOComponent.kt index a7926af0f..c54864f9c 100644 --- a/sdk/src/main/java/io/customer/sdk/di/CustomerIOComponent.kt +++ b/sdk/src/main/java/io/customer/sdk/di/CustomerIOComponent.kt @@ -16,28 +16,11 @@ import io.customer.sdk.data.moshi.adapter.BigDecimalAdapter import io.customer.sdk.data.moshi.adapter.CustomAttributesFactory import io.customer.sdk.data.moshi.adapter.UnixDateAdapter import io.customer.sdk.data.store.* -import io.customer.sdk.queue.Queue -import io.customer.sdk.queue.QueueImpl -import io.customer.sdk.queue.QueueQueryRunner -import io.customer.sdk.queue.QueueQueryRunnerImpl -import io.customer.sdk.queue.QueueRunRequest -import io.customer.sdk.queue.QueueRunRequestImpl -import io.customer.sdk.queue.QueueRunner -import io.customer.sdk.queue.QueueRunnerImpl -import io.customer.sdk.queue.QueueStorage -import io.customer.sdk.queue.QueueStorageImpl +import io.customer.sdk.hooks.CioHooksManager +import io.customer.sdk.hooks.HooksManager +import io.customer.sdk.queue.* import io.customer.sdk.repository.* -import io.customer.sdk.util.AndroidSimpleTimer -import io.customer.sdk.util.DateUtil -import io.customer.sdk.util.DateUtilImpl -import io.customer.sdk.util.DispatchersProvider -import io.customer.sdk.util.JsonAdapter -import io.customer.sdk.util.LogcatLogger -import io.customer.sdk.util.Logger -import io.customer.sdk.util.PushTrackingUtil -import io.customer.sdk.util.PushTrackingUtilImpl -import io.customer.sdk.util.SdkDispatchers -import io.customer.sdk.util.SimpleTimer +import io.customer.sdk.util.* import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -82,16 +65,29 @@ class CustomerIOComponent( get() = override() ?: QueueQueryRunnerImpl(logger) val queueRunRequest: QueueRunRequest - get() = override() ?: QueueRunRequestImpl(queueRunner, queueStorage, logger, queueQueryRunner) + get() = override() ?: QueueRunRequestImpl( + queueRunner, + queueStorage, + logger, + queueQueryRunner + ) val logger: Logger get() = override() ?: LogcatLogger(sdkConfig) + val hooksManager: HooksManager + get() = override() ?: getSingletonInstanceCreate { CioHooksManager() } + internal val cioHttpClient: TrackingHttpClient get() = override() ?: RetrofitTrackingHttpClient(buildRetrofitApi(), httpRequestRunner) private val httpRequestRunner: HttpRequestRunner - get() = HttpRequestRunnerImpl(sharedPreferenceRepository, logger, cioHttpRetryPolicy, jsonAdapter) + get() = HttpRequestRunnerImpl( + sharedPreferenceRepository, + logger, + cioHttpRetryPolicy, + jsonAdapter + ) val cioHttpRetryPolicy: HttpRetryPolicy get() = override() ?: CustomerIOApiRetryPolicy() @@ -103,13 +99,31 @@ class CustomerIOComponent( get() = override() ?: AndroidSimpleTimer(logger, dispatchersProvider) val trackRepository: TrackRepository - get() = override() ?: TrackRepositoryImpl(sharedPreferenceRepository, queue, logger) + get() = override() ?: TrackRepositoryImpl( + sharedPreferenceRepository, + queue, + logger, + hooksManager + ) val profileRepository: ProfileRepository - get() = override() ?: ProfileRepositoryImpl(deviceRepository, sharedPreferenceRepository, queue, logger) + get() = override() ?: ProfileRepositoryImpl( + deviceRepository, + sharedPreferenceRepository, + queue, + logger, + hooksManager + ) val deviceRepository: DeviceRepository - get() = override() ?: DeviceRepositoryImpl(sdkConfig, buildStore().deviceStore, sharedPreferenceRepository, queue, dateUtil, logger) + get() = override() ?: DeviceRepositoryImpl( + sdkConfig, + buildStore().deviceStore, + sharedPreferenceRepository, + queue, + dateUtil, + logger + ) fun buildStore(): CustomerIOStore { return override() ?: object : CustomerIOStore { diff --git a/sdk/src/main/java/io/customer/sdk/hooks/HooksManager.kt b/sdk/src/main/java/io/customer/sdk/hooks/HooksManager.kt new file mode 100644 index 000000000..7645c5d0d --- /dev/null +++ b/sdk/src/main/java/io/customer/sdk/hooks/HooksManager.kt @@ -0,0 +1,33 @@ +package io.customer.sdk.hooks + +interface HooksManager { + fun add(module: HookModule, subscriber: ModuleHookProvider) + fun onHookUpdate(hook: ModuleHook) +} + +enum class HookModule { + MessagingPush, MessagingInApp +} + +internal class CioHooksManager : HooksManager { + + private val map: MutableMap = mutableMapOf() + + override fun add(module: HookModule, subscriber: ModuleHookProvider) { + map[module] = subscriber + } + + override fun onHookUpdate(hook: ModuleHook) { + when (hook) { + is ModuleHook.ProfileIdentifiedHook -> map.values.forEach { + it.profileIdentifiedHook(hook) + } + is ModuleHook.BeforeProfileStoppedBeingIdentified -> map.values.forEach { + it.beforeProfileStoppedBeingIdentified(hook) + } + is ModuleHook.ScreenTrackedHook -> map.values.forEach { + it.screenTrackedHook(hook) + } + } + } +} diff --git a/sdk/src/main/java/io/customer/sdk/hooks/ModuleHook.kt b/sdk/src/main/java/io/customer/sdk/hooks/ModuleHook.kt new file mode 100644 index 000000000..3a6149b66 --- /dev/null +++ b/sdk/src/main/java/io/customer/sdk/hooks/ModuleHook.kt @@ -0,0 +1,19 @@ +package io.customer.sdk.hooks + +abstract class ModuleHookProvider { + open fun profileIdentifiedHook(hook: ModuleHook.ProfileIdentifiedHook) {} + open fun beforeProfileStoppedBeingIdentified(hook: ModuleHook.BeforeProfileStoppedBeingIdentified) {} + open fun screenTrackedHook(hook: ModuleHook.ScreenTrackedHook) {} +} + +sealed class ModuleHook { + + // Hook to notify when a profile is newly identified in the SDK. + class ProfileIdentifiedHook(val identifier: String) : ModuleHook() + + // Hook to notify when profile is stopped being identified. + class BeforeProfileStoppedBeingIdentified(val identifier: String) : ModuleHook() + + // Hook to notify when a screen is tracked in SDK. + class ScreenTrackedHook(val screen: String) : ModuleHook() +} diff --git a/sdk/src/main/java/io/customer/sdk/hooks/hooks/QueueRunnerHook.kt b/sdk/src/main/java/io/customer/sdk/hooks/hooks/QueueRunnerHook.kt deleted file mode 100644 index 60d6d74e2..000000000 --- a/sdk/src/main/java/io/customer/sdk/hooks/hooks/QueueRunnerHook.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.customer.sdk.hooks.hooks - -import io.customer.sdk.queue.type.QueueRunTaskResult -import io.customer.sdk.queue.type.QueueTask - -// When a module wants to run background queue tasks, they implement this hook. -interface QueueRunnerHook { - // called from background queue in `Tracking` module. - // if queue task does *not* belong to the module, return null and the queue will send the task to another module. - suspend fun runTask(task: QueueTask): QueueRunTaskResult? -} diff --git a/sdk/src/main/java/io/customer/sdk/queue/Queue.kt b/sdk/src/main/java/io/customer/sdk/queue/Queue.kt index 15d07569c..f40aea577 100644 --- a/sdk/src/main/java/io/customer/sdk/queue/Queue.kt +++ b/sdk/src/main/java/io/customer/sdk/queue/Queue.kt @@ -3,10 +3,7 @@ package io.customer.sdk.queue import io.customer.sdk.CustomerIOConfig import io.customer.sdk.data.model.CustomAttributes import io.customer.sdk.data.model.EventType -import io.customer.sdk.data.request.Device -import io.customer.sdk.data.request.Event -import io.customer.sdk.data.request.Metric -import io.customer.sdk.data.request.MetricEvent +import io.customer.sdk.data.request.* import io.customer.sdk.queue.taskdata.DeletePushNotificationQueueTaskData import io.customer.sdk.queue.taskdata.IdentifyProfileQueueTaskData import io.customer.sdk.queue.taskdata.RegisterPushNotificationQueueTaskData @@ -25,11 +22,28 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch interface Queue { - fun queueIdentifyProfile(newIdentifier: String, oldIdentifier: String?, attributes: CustomAttributes): QueueModifyResult - fun queueTrack(identifiedProfileId: String, name: String, eventType: EventType, attributes: CustomAttributes): QueueModifyResult + fun queueIdentifyProfile( + newIdentifier: String, + oldIdentifier: String?, + attributes: CustomAttributes + ): QueueModifyResult + + fun queueTrack( + identifiedProfileId: String, + name: String, + eventType: EventType, + attributes: CustomAttributes + ): QueueModifyResult + fun queueRegisterDevice(identifiedProfileId: String, device: Device): QueueModifyResult fun queueDeletePushToken(identifiedProfileId: String, deviceToken: String): QueueModifyResult - fun queueTrackMetric(deliveryId: String, deviceToken: String, event: MetricEvent): QueueModifyResult + fun queueTrackMetric( + deliveryId: String, + deviceToken: String, + event: MetricEvent + ): QueueModifyResult + + fun queueTrackInAppMetric(deliveryId: String, event: MetricEvent): QueueModifyResult fun , TaskData : Any> addTask( type: TaskType, @@ -57,7 +71,8 @@ class QueueImpl internal constructor( private val numberSecondsToScheduleTimer: Seconds get() = Seconds(sdkConfig.backgroundQueueSecondsDelay) - @Volatile var isRunningRequest: Boolean = false + @Volatile + var isRunningRequest: Boolean = false override fun , TaskData : Any> addTask( type: TaskType, @@ -112,7 +127,8 @@ class QueueImpl internal constructor( private fun processQueueStatus(queueStatus: QueueStatus) { logger.debug("processing queue status $queueStatus") - val isManyTasksInQueue = queueStatus.numTasksInQueue >= sdkConfig.backgroundQueueMinNumberOfTasks + val isManyTasksInQueue = + queueStatus.numTasksInQueue >= sdkConfig.backgroundQueueMinNumberOfTasks if (isManyTasksInQueue) { logger.info("queue met criteria to run automatically") @@ -143,7 +159,10 @@ class QueueImpl internal constructor( ) } - override fun queueDeletePushToken(identifiedProfileId: String, deviceToken: String): QueueModifyResult { + override fun queueDeletePushToken( + identifiedProfileId: String, + deviceToken: String + ): QueueModifyResult { return addTask( QueueTaskType.DeletePushToken, DeletePushNotificationQueueTaskData(identifiedProfileId, deviceToken), @@ -189,6 +208,24 @@ class QueueImpl internal constructor( ) } + override fun queueTrackInAppMetric( + deliveryId: String, + event: MetricEvent + ): QueueModifyResult { + return addTask( + QueueTaskType.TrackDeliveryEvent, + DeliveryEvent( + type = DeliveryType.in_app, + payload = DeliveryPayload( + deliveryID = deliveryId, + event = event, + timestamp = dateUtil.now + ) + ), + blockingGroups = emptyList() + ) + } + override fun queueIdentifyProfile( newIdentifier: String, oldIdentifier: String?, @@ -198,10 +235,14 @@ class QueueImpl internal constructor( val isChangingIdentifiedProfile = oldIdentifier != null && oldIdentifier != newIdentifier // If SDK previously identified profile X and X is being identified again, no use blocking the queue with a queue group. - val queueGroupStart = if (isFirstTimeIdentifying || isChangingIdentifiedProfile) QueueTaskGroup.IdentifyProfile(newIdentifier) else null + val queueGroupStart = + if (isFirstTimeIdentifying || isChangingIdentifiedProfile) QueueTaskGroup.IdentifyProfile( + newIdentifier + ) else null // If there was a previously identified profile, or, we are just adding attributes to an existing profile, we need to wait for // this operation until the previous identify runs successfully. - val blockingGroups = if (!isFirstTimeIdentifying) listOf(QueueTaskGroup.IdentifyProfile(oldIdentifier!!)) else null + val blockingGroups = + if (!isFirstTimeIdentifying) listOf(QueueTaskGroup.IdentifyProfile(oldIdentifier!!)) else null return addTask( QueueTaskType.IdentifyProfile, diff --git a/sdk/src/main/java/io/customer/sdk/queue/QueueRunner.kt b/sdk/src/main/java/io/customer/sdk/queue/QueueRunner.kt index a3c62248a..8a3c44dc2 100644 --- a/sdk/src/main/java/io/customer/sdk/queue/QueueRunner.kt +++ b/sdk/src/main/java/io/customer/sdk/queue/QueueRunner.kt @@ -1,6 +1,7 @@ package io.customer.sdk.queue import io.customer.sdk.api.TrackingHttpClient +import io.customer.sdk.data.request.DeliveryEvent import io.customer.sdk.data.request.Metric import io.customer.sdk.extensions.valueOfOrNull import io.customer.sdk.queue.taskdata.DeletePushNotificationQueueTaskData @@ -29,8 +30,10 @@ internal class QueueRunnerImpl( QueueTaskType.RegisterDeviceToken -> registerDeviceToken(task) QueueTaskType.DeletePushToken -> deleteDeviceToken(task) QueueTaskType.TrackPushMetric -> trackPushMetrics(task) + QueueTaskType.TrackDeliveryEvent -> trackDeliveryEvents(task) null -> { - val errorMessage = "Queue task ${task.type} could not find an enum to map to. Could not run task." + val errorMessage = + "Queue task ${task.type} could not find an enum to map to. Could not run task." logger.error(errorMessage) return Result.failure(RuntimeException(errorMessage)) } @@ -69,4 +72,10 @@ internal class QueueRunnerImpl( return cioHttpClient.trackPushMetrics(taskData) } + + private suspend fun trackDeliveryEvents(task: QueueTask): QueueRunTaskResult { + val taskData: DeliveryEvent = jsonAdapter.fromJson(task.data) + + return cioHttpClient.trackDeliveryEvents(taskData) + } } diff --git a/sdk/src/main/java/io/customer/sdk/queue/type/QueueTaskType.kt b/sdk/src/main/java/io/customer/sdk/queue/type/QueueTaskType.kt index b1f00d85b..6a73537b9 100644 --- a/sdk/src/main/java/io/customer/sdk/queue/type/QueueTaskType.kt +++ b/sdk/src/main/java/io/customer/sdk/queue/type/QueueTaskType.kt @@ -6,5 +6,6 @@ internal enum class QueueTaskType { TrackEvent, RegisterDeviceToken, DeletePushToken, - TrackPushMetric + TrackPushMetric, + TrackDeliveryEvent } diff --git a/sdk/src/main/java/io/customer/sdk/repository/ProfileRepository.kt b/sdk/src/main/java/io/customer/sdk/repository/ProfileRepository.kt index 46eee4792..470529727 100644 --- a/sdk/src/main/java/io/customer/sdk/repository/ProfileRepository.kt +++ b/sdk/src/main/java/io/customer/sdk/repository/ProfileRepository.kt @@ -1,6 +1,8 @@ package io.customer.sdk.repository import io.customer.sdk.data.model.CustomAttributes +import io.customer.sdk.hooks.HooksManager +import io.customer.sdk.hooks.ModuleHook import io.customer.sdk.queue.Queue import io.customer.sdk.util.Logger @@ -14,7 +16,8 @@ class ProfileRepositoryImpl( private val deviceRepository: DeviceRepository, private val preferenceRepository: PreferenceRepository, private val backgroundQueue: Queue, - private val logger: Logger + private val logger: Logger, + private val hooksManager: HooksManager ) : ProfileRepository { override fun identify(identifier: String, attributes: CustomAttributes) { @@ -23,7 +26,8 @@ class ProfileRepositoryImpl( val currentlyIdentifiedProfileIdentifier = preferenceRepository.getIdentifier() // The SDK calls identify() with the already identified profile for changing profile attributes. - val isChangingIdentifiedProfile = currentlyIdentifiedProfileIdentifier != null && currentlyIdentifiedProfileIdentifier != identifier + val isChangingIdentifiedProfile = + currentlyIdentifiedProfileIdentifier != null && currentlyIdentifiedProfileIdentifier != identifier val isFirstTimeIdentifying = currentlyIdentifiedProfileIdentifier == null currentlyIdentifiedProfileIdentifier?.let { currentlyIdentifiedProfileIdentifier -> @@ -35,7 +39,11 @@ class ProfileRepositoryImpl( } } - val queueStatus = backgroundQueue.queueIdentifyProfile(identifier, currentlyIdentifiedProfileIdentifier, attributes) + val queueStatus = backgroundQueue.queueIdentifyProfile( + identifier, + currentlyIdentifiedProfileIdentifier, + attributes + ) // Don't modify the state of the SDK's data until we confirm we added a queue task successfully. This could put the Customer.io API // out-of-sync with the SDK's state and cause many future HTTP errors. @@ -48,12 +56,19 @@ class ProfileRepositoryImpl( logger.debug("storing identifier on device storage $identifier") preferenceRepository.saveIdentifier(identifier) + hooksManager.onHookUpdate( + hook = ModuleHook.ProfileIdentifiedHook(identifier) + ) + if (isFirstTimeIdentifying || isChangingIdentifiedProfile) { logger.debug("first time identified or changing identified profile") preferenceRepository.getDeviceToken()?.let { logger.debug("automatically registering device token to newly identified profile") - deviceRepository.registerDeviceToken(it, emptyMap()) // no new attributes but default ones to pass so pass empty. + deviceRepository.registerDeviceToken( + it, + emptyMap() + ) // no new attributes but default ones to pass so pass empty. } } } @@ -81,6 +96,13 @@ class ProfileRepositoryImpl( return } + // notify hooks about identifier being cleared + hooksManager.onHookUpdate( + ModuleHook.BeforeProfileStoppedBeingIdentified( + currentlyIdentifiedProfileId + ) + ) + // delete token from profile to prevent sending the profile pushes when they are not identified in the SDK. deviceRepository.deleteDeviceToken() diff --git a/sdk/src/main/java/io/customer/sdk/repository/TrackRepository.kt b/sdk/src/main/java/io/customer/sdk/repository/TrackRepository.kt index a78ae7bdf..7fdb551ea 100644 --- a/sdk/src/main/java/io/customer/sdk/repository/TrackRepository.kt +++ b/sdk/src/main/java/io/customer/sdk/repository/TrackRepository.kt @@ -3,19 +3,23 @@ package io.customer.sdk.repository import io.customer.sdk.data.model.CustomAttributes import io.customer.sdk.data.model.EventType import io.customer.sdk.data.request.MetricEvent +import io.customer.sdk.hooks.HooksManager +import io.customer.sdk.hooks.ModuleHook import io.customer.sdk.queue.Queue import io.customer.sdk.util.Logger interface TrackRepository { fun track(name: String, attributes: CustomAttributes) fun trackMetric(deliveryID: String, event: MetricEvent, deviceToken: String) + fun trackInAppMetric(deliveryID: String, event: MetricEvent) fun screen(name: String, attributes: CustomAttributes) } class TrackRepositoryImpl( private val preferenceRepository: PreferenceRepository, private val backgroundQueue: Queue, - private val logger: Logger + private val logger: Logger, + private val hooksManager: HooksManager ) : TrackRepository { override fun track(name: String, attributes: CustomAttributes) { @@ -27,7 +31,8 @@ class TrackRepositoryImpl( } private fun track(eventType: EventType, name: String, attributes: CustomAttributes) { - val eventTypeDescription = if (eventType == EventType.screen) "track screen view event" else "track event" + val eventTypeDescription = + if (eventType == EventType.screen) "track screen view event" else "track event" logger.info("$eventTypeDescription $name") logger.debug("$eventTypeDescription $name attributes: $attributes") @@ -40,8 +45,13 @@ class TrackRepositoryImpl( return } - // if task doesn't successfully get added to the queue, it does not break the SDK's state. So, we can ignore the result of adding task to queue. - backgroundQueue.queueTrack(identifier, name, eventType, attributes) + val queueStatus = backgroundQueue.queueTrack(identifier, name, eventType, attributes) + + if (queueStatus.success && eventType == EventType.screen) { + hooksManager.onHookUpdate( + hook = ModuleHook.ScreenTrackedHook(name) + ) + } } override fun trackMetric( @@ -55,4 +65,15 @@ class TrackRepositoryImpl( // if task doesn't successfully get added to the queue, it does not break the SDK's state. So, we can ignore the result of adding task to queue. backgroundQueue.queueTrackMetric(deliveryID, deviceToken, event) } + + override fun trackInAppMetric( + deliveryID: String, + event: MetricEvent + ) { + logger.info("in-app metric ${event.name}") + logger.debug("delivery id $deliveryID") + + // if task doesn't successfully get added to the queue, it does not break the SDK's state. So, we can ignore the result of adding task to queue. + backgroundQueue.queueTrackInAppMetric(deliveryID, event) + } } diff --git a/sdk/src/sharedTest/java/io/customer/sdk/hook/HookManagerTest.kt b/sdk/src/sharedTest/java/io/customer/sdk/hook/HookManagerTest.kt new file mode 100644 index 000000000..c66ed83ea --- /dev/null +++ b/sdk/src/sharedTest/java/io/customer/sdk/hook/HookManagerTest.kt @@ -0,0 +1,91 @@ +package io.customer.sdk.hook + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.customer.commontest.BaseTest +import io.customer.sdk.hooks.CioHooksManager +import io.customer.sdk.hooks.HookModule +import io.customer.sdk.hooks.ModuleHook +import io.customer.sdk.hooks.ModuleHookProvider +import io.customer.sdk.utils.random +import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class HookManagerTest : BaseTest() { + + private lateinit var cioHooksManager: CioHooksManager + + @Before + override fun setup() { + super.setup() + + cioHooksManager = CioHooksManager() + } + + @Test + fun subscribeToUpdate_givenSubscribedToHooksManager_expectGetHookUpdate() { + val identifier = String.random + val profileIdentifiedHook = ModuleHook.ProfileIdentifiedHook(identifier) + + val beforeProfileStoppedBeingIdentifiedHook = + ModuleHook.BeforeProfileStoppedBeingIdentified(identifier) + + val screen = String.random + val screenTrackedHook = ModuleHook.ScreenTrackedHook(screen) + + var didProfileIdentifyHookGetCalled = false + var didBeforeProfileStoppedBeingIdentifiedGetCalled = false + var didScreenTrackedHookGetCalled = false + + cioHooksManager.add( + HookModule.MessagingInApp, + object : ModuleHookProvider() { + override fun profileIdentifiedHook(hook: ModuleHook.ProfileIdentifiedHook) { + didProfileIdentifyHookGetCalled = true + assertEquals(hook, profileIdentifiedHook) + assertEquals(hook.identifier, identifier) + } + + override fun beforeProfileStoppedBeingIdentified(hook: ModuleHook.BeforeProfileStoppedBeingIdentified) { + didBeforeProfileStoppedBeingIdentifiedGetCalled = true + assertEquals(hook, beforeProfileStoppedBeingIdentifiedHook) + assertEquals(hook.identifier, identifier) + } + + override fun screenTrackedHook(hook: ModuleHook.ScreenTrackedHook) { + didScreenTrackedHookGetCalled = true + assertEquals(hook, screenTrackedHook) + assertEquals(hook.screen, screen) + } + } + ) + + val availableHooks = listOf( + profileIdentifiedHook, + beforeProfileStoppedBeingIdentifiedHook, + screenTrackedHook + ) + + // its enum so whenever a new enum gets added, test would need updating as well + availableHooks.forEach { + when (it) { + is ModuleHook.BeforeProfileStoppedBeingIdentified -> { + cioHooksManager.onHookUpdate(profileIdentifiedHook) + } + is ModuleHook.ProfileIdentifiedHook -> { + cioHooksManager.onHookUpdate(beforeProfileStoppedBeingIdentifiedHook) + } + is ModuleHook.ScreenTrackedHook -> { + cioHooksManager.onHookUpdate(screenTrackedHook) + } + } + } + + didProfileIdentifyHookGetCalled shouldBeEqualTo true + didBeforeProfileStoppedBeingIdentifiedGetCalled shouldBeEqualTo true + didScreenTrackedHookGetCalled shouldBeEqualTo true + } +} diff --git a/sdk/src/sharedTest/java/io/customer/sdk/repository/ProfileRepositoryTest.kt b/sdk/src/sharedTest/java/io/customer/sdk/repository/ProfileRepositoryTest.kt index 45c8904c3..2934764db 100644 --- a/sdk/src/sharedTest/java/io/customer/sdk/repository/ProfileRepositoryTest.kt +++ b/sdk/src/sharedTest/java/io/customer/sdk/repository/ProfileRepositoryTest.kt @@ -2,23 +2,20 @@ package io.customer.sdk.repository import androidx.test.ext.junit.runners.AndroidJUnit4 import io.customer.commontest.BaseTest +import io.customer.sdk.hooks.HooksManager +import io.customer.sdk.hooks.ModuleHook import io.customer.sdk.queue.Queue import io.customer.sdk.queue.type.QueueModifyResult import io.customer.sdk.queue.type.QueueStatus import io.customer.sdk.util.Logger import io.customer.sdk.utils.random +import org.amshove.kluent.internal.assertEquals import org.amshove.kluent.shouldBeNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.verifyNoInteractions -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.inOrder -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoMoreInteractions -import org.mockito.kotlin.whenever +import org.mockito.kotlin.* @RunWith(AndroidJUnit4::class) class ProfileRepositoryTest : BaseTest() { @@ -27,6 +24,7 @@ class ProfileRepositoryTest : BaseTest() { get() = di.sharedPreferenceRepository private val backgroundQueueMock: Queue = mock() private val loggerMock: Logger = mock() + private val hooksManager: HooksManager = mock() private val deviceRepositoryMock: DeviceRepository = mock() private lateinit var repository: ProfileRepository @@ -39,7 +37,8 @@ class ProfileRepositoryTest : BaseTest() { deviceRepository = deviceRepositoryMock, preferenceRepository = prefRepository, backgroundQueue = backgroundQueueMock, - logger = loggerMock + logger = loggerMock, + hooksManager = hooksManager ) } @@ -49,7 +48,13 @@ class ProfileRepositoryTest : BaseTest() { fun identify_givenFirstTimeIdentify_givenNoDeviceTokenRegistered_expectIdentifyBackgroundQueue_expectDoNotDeleteToken_expectDoNotRegisterToken() { val newIdentifier = String.random val givenAttributes = mapOf("name" to String.random) - whenever(backgroundQueueMock.queueIdentifyProfile(anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) + whenever( + backgroundQueueMock.queueIdentifyProfile( + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) repository.identify(newIdentifier, givenAttributes) @@ -62,15 +67,26 @@ class ProfileRepositoryTest : BaseTest() { } @Test - fun identify_givenFirstTimeIdentify_givenDeviceTokenExists_expectIdentifyBackgroundQueue_expectDoNotDeleteToken_expectRegisterDeviceToken() { + fun identify_givenFirstTimeIdentify_givenDeviceTokenExists_expectIdentifyBackgroundQueue_expectDoNotDeleteToken_expectProfileIdentifiedHookUpdateWithCorrectIdentifier_expectRegisterDeviceToken() { val newIdentifier = String.random val givenDeviceToken = String.random val givenAttributes = mapOf("name" to String.random) prefRepository.saveDeviceToken(givenDeviceToken) - whenever(backgroundQueueMock.queueIdentifyProfile(anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) + whenever( + backgroundQueueMock.queueIdentifyProfile( + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) repository.identify(newIdentifier, givenAttributes) + argumentCaptor().apply { + verify(hooksManager, times(1)).onHookUpdate(capture()) + assertEquals(newIdentifier, firstValue.identifier) + } + inOrder(backgroundQueueMock, deviceRepositoryMock).apply { verify(backgroundQueueMock).queueIdentifyProfile( newIdentifier = newIdentifier, @@ -89,7 +105,13 @@ class ProfileRepositoryTest : BaseTest() { val newIdentifier = String.random val givenAttributes = mapOf("name" to String.random) prefRepository.saveIdentifier(givenIdentifier) - whenever(backgroundQueueMock.queueIdentifyProfile(anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) + whenever( + backgroundQueueMock.queueIdentifyProfile( + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) repository.identify(newIdentifier, givenAttributes) @@ -109,7 +131,13 @@ class ProfileRepositoryTest : BaseTest() { val givenAttributes = mapOf("name" to String.random) prefRepository.saveIdentifier(givenIdentifier) prefRepository.saveDeviceToken(givenDeviceToken) - whenever(backgroundQueueMock.queueIdentifyProfile(anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) + whenever( + backgroundQueueMock.queueIdentifyProfile( + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) repository.identify(newIdentifier, givenAttributes) @@ -136,7 +164,13 @@ class ProfileRepositoryTest : BaseTest() { val givenAttributes = mapOf("name" to String.random) prefRepository.saveIdentifier(givenIdentifier) prefRepository.saveDeviceToken(givenDeviceToken) - whenever(backgroundQueueMock.queueIdentifyProfile(anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) + whenever( + backgroundQueueMock.queueIdentifyProfile( + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) repository.identify(givenIdentifier, givenAttributes) @@ -162,11 +196,31 @@ class ProfileRepositoryTest : BaseTest() { } @Test - fun clearIdentify_givenNoPreviouslyIdentifiedProfile_expectIgnoreRequest_expectDontDeleteDeviceToken() { + fun clearIdentify_givenNoPreviouslyIdentifiedProfile_expectIgnoreRequest_expectDontDeleteDeviceToken_expectDontUpdateHook() { repository.clearIdentify() prefRepository.getIdentifier().shouldBeNull() + verify(deviceRepositoryMock, never()).deleteDeviceToken() + verify(hooksManager, never()).onHookUpdate(any()) + } + + @Test + fun clearIdentify_givenPreviouslyIdentifiedProfile_expectHookUpdate() { + val givenIdentifier = String.random + prefRepository.saveIdentifier(givenIdentifier) + + repository.clearIdentify() + + prefRepository.getIdentifier().shouldBeNull() + + val argumentCaptor = + argumentCaptor() + + verify(hooksManager, times(1)).onHookUpdate( + argumentCaptor.capture() + ) + assertEquals(givenIdentifier, argumentCaptor.firstValue.identifier) } // addCustomProfileAttributes @@ -186,11 +240,21 @@ class ProfileRepositoryTest : BaseTest() { val givenAttributes = mapOf(String.random to String.random) val givenIdentifier = String.random prefRepository.saveIdentifier(givenIdentifier) - whenever(backgroundQueueMock.queueIdentifyProfile(anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) + whenever( + backgroundQueueMock.queueIdentifyProfile( + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) repository.addCustomProfileAttributes(givenAttributes) // assert that attributes have been added to a profile - verify(backgroundQueueMock).queueIdentifyProfile(givenIdentifier, givenIdentifier, givenAttributes) + verify(backgroundQueueMock).queueIdentifyProfile( + givenIdentifier, + givenIdentifier, + givenAttributes + ) } } diff --git a/sdk/src/sharedTest/java/io/customer/sdk/repository/TrackRepositoryTest.kt b/sdk/src/sharedTest/java/io/customer/sdk/repository/TrackRepositoryTest.kt index 493ffcb62..e67b3ba83 100644 --- a/sdk/src/sharedTest/java/io/customer/sdk/repository/TrackRepositoryTest.kt +++ b/sdk/src/sharedTest/java/io/customer/sdk/repository/TrackRepositoryTest.kt @@ -4,15 +4,16 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.customer.commontest.BaseTest import io.customer.sdk.data.model.EventType import io.customer.sdk.data.request.MetricEvent +import io.customer.sdk.hooks.HooksManager import io.customer.sdk.queue.Queue +import io.customer.sdk.queue.type.QueueModifyResult +import io.customer.sdk.queue.type.QueueStatus import io.customer.sdk.util.Logger import io.customer.sdk.utils.random import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.* @RunWith(AndroidJUnit4::class) class TrackRepositoryTest : BaseTest() { @@ -23,6 +24,7 @@ class TrackRepositoryTest : BaseTest() { private val loggerMock: Logger = mock() private lateinit var repository: TrackRepository + private val hooksManager: HooksManager = mock() @Before override fun setup() { @@ -31,7 +33,8 @@ class TrackRepositoryTest : BaseTest() { repository = TrackRepositoryImpl( preferenceRepository = prefRepository, backgroundQueue = backgroundQueueMock, - logger = loggerMock + logger = loggerMock, + hooksManager = hooksManager ) } @@ -51,6 +54,15 @@ class TrackRepositoryTest : BaseTest() { val givenAttributes = mapOf("foo" to String.random) prefRepository.saveIdentifier(givenIdentifier) + whenever( + backgroundQueueMock.queueTrack( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(QueueModifyResult(true, QueueStatus(siteId, 1))) + repository.track(givenTrackEventName, givenAttributes) verify(backgroundQueueMock).queueTrack( diff --git a/settings.gradle b/settings.gradle index 1b77c50e7..a48efc7c7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,3 +4,4 @@ rootProject.name = "Customer.io SDK" include ':base' include ':messagingpush' include ':common-test' +include ':messaginginapp'