From 798d03d44f4711b6c27508a29165239c7994e344 Mon Sep 17 00:00:00 2001 From: Evan C Masseau <5167687+evan-masseau@users.noreply.github.com> Date: Wed, 23 Oct 2024 10:32:14 -0400 Subject: [PATCH] Staging branch for next major release 3.0.0 (#161) * Separation of API, State, and Side Effects (#160) * Separation of API, State and Side Effects. Largely left tests alone except for adding a couple and adjusting setup to accommodate changes. * Use consistent nullability pattern so we aren't converting between empty string and null with identifiers. Added proper deserializing of the profile attributes store property * There is indeed a better way to do that * Renamed some of the new classes and added new tests for them * Doc blocks, minor cleanup, naming conventions * Fix nullability test Don't need immutable profile to expose attributes, but would be good to expose single attribute getter * First round of PR comments * missed another arg label --------- Co-authored-by: Evan Masseau <> * Set an alpha version number on this branch in case it gets pulled by a snapshot jitpack build * Remove deprecated lifecycle property (#156) Co-authored-by: Evan Masseau <> * Transition profile identifier keys to internal visibility (#155) Co-authored-by: Evan Masseau <> * Add Unregister Push Token Request (#162) * Publish old value of a state property (#164) Co-authored-by: Evan Masseau <> * Rename these properties so it is clear what true and false mean. Fix polarity of how we check background data (#166) Co-authored-by: Evan Masseau <> * Added tests for publishing of the old value (#165) * Added test coverage for publishing old value with change callback * Broadcast on reset, and tests * Reset as a cleanup step, not a setup step. --------- Co-authored-by: Evan Masseau <> * Trigger Unregister on API Key Change (#163) * Using token endpoint for profile requests when a push token is present in SDK state (#168) * making token request if token is present * minor refactor * fixed tests * added some tests * removed push state when resetting * updated tests * fixed side effects tests * removed unused comment * updated readme * finally fixed tests * Fix some setup/teardown in tests to take care of isolation issues. (#169) Co-authored-by: Evan Masseau <> --------- Co-authored-by: Evan C Masseau <5167687+evan-masseau@users.noreply.github.com> * API Header Fix: Move attempt count increment to before we write request headers into the url connection. (#170) Co-authored-by: Evan Masseau <> * setProfileAttribute should accept any Serializable value not just String (#179) * changing profile attribute to accept serializable * updating for 3.0 release * logging incorrectly typed profile attribute * Concurrent network observer fix (#180) * using synchronized list for observers * adding concurreny safe structure to all observer instances * Refresh versions syntax for dependencies * removing concurrent suffix --------- Co-authored-by: Evan Masseau <> * Retry on 503 (#181) * Sending 503 through retry logic * removing version.properties unecessary changes * fixing http import * CHNL-6996 Proguard docs and consumer rules (#184) * Adding consumer rules and updating docs * progaurd changes * Introduce a de-dupe mechanism for push notifications (#177) * Introduce tag support so we have the option to de-dupe push notifications similar to how stock FCM sdk can. * adding constant id and null-checked notification tag --------- Co-authored-by: Evan Masseau <> Co-authored-by: Daniel Peluso * CHNL-3990 Remove identifiers from state if format issues (#186) * Remove identifiers from state if API reports format errors * pr comments * removing extraneous decoder test * Exposing klaviyo SDK name and version privately (#185) * added klaviyo sdk name and version * Update sdk/analytics/src/main/java/com/klaviyo/analytics/DeviceProperties.kt Co-authored-by: dan-peluso * fixed some formatting * updated the SDK name to be non lazy --------- Co-authored-by: dan-peluso * Refactor singleton service initializers (#172) * Convert SystemClock to class * These shouldn't have been mocked in this test class, its masking a bug. * Remove unnecessary, slightly risky, object initializers in core. * Restore system clock to object -- I take it back, we don't have to get rid of objects, just have to be more particular about using object initializers. * KLog can be a singleton object, no risk there. * This simple fix would take care of the issue, but i still don't love KlaviyoApiClient being a singleton object that can hit an unrecoverable exception in its initializer... * Found a balanced solution for KlaviyoApiClient -- converting this to a class was complicated by the inner class, and besides it works well as a singleton. The initializer was the real problem, so I extract the listeners from the initializer and created a startService method. This is safe to run multiple times if the SDK is re-initialized. * Prefer this way: we don't need to wrap any of the code in this method body except the methods that are themselves protected anyway * Forgot to add back starting the service in these tests' setup * Naming convention things * First shot at a pre-init buffer for failed operations. Needs tests * Added test of in-memory slate * I don't like import * * Added comments, logging, and wrapped the retry operations in safeCall * Trivial: move error logging out of exception class. Now that we wrap all SDK functions in safeCall, it is more straightforward to log from there. * Allow ONLY handlePush to be buffered * Remove unused test * keep 3.0 version increment * don't add this back * never trust github * Update versions.properties --------- Co-authored-by: Evan Masseau <> * Automatically check push permission status on app resume (#188) * Protect against double subscriptions * readme and migration guide updates --------- Co-authored-by: Evan Masseau <> * Move API revision to core config property pulled from build property (#190) Co-authored-by: Evan Masseau <> * [CHNL-12521] Looking for react-native strings to determine sdk name and version for config (#191) * resource reading maven attmept * working resource share attempt * update log level * fixing unit tests and working resource sharing * pr comments * fixing config * replace bump version task with xml-based task * fixing unit test * fixing file pathing for composite builds * fixing bump version task * removing build config field from build.gradle * using readXml for default config * removing unused versionFor import * removing capability change broadcast (#192) * renaming removals to 'breaking changes' (#193) * bumping version --------- Co-authored-by: Evan Masseau <> Co-authored-by: Kenny Tsui <63658871+kennyklaviyo@users.noreply.github.com> Co-authored-by: Ajay Subramanya <118314354+ajaysubra@users.noreply.github.com> Co-authored-by: dan-peluso --- MIGRATION_GUIDE.md | 13 + README.md | 30 +- build.gradle | 42 ++- docs/index.html | 2 +- gradle.properties | 3 + sdk/analytics/build.gradle | 3 +- sdk/analytics/consumer-rules.pro | 1 + .../com/klaviyo/analytics/DeviceProperties.kt | 18 +- .../java/com/klaviyo/analytics/Klaviyo.kt | 132 ++----- .../analytics/ProfileOperationQueue.kt | 52 --- .../java/com/klaviyo/analytics/UserInfo.kt | 149 -------- .../com/klaviyo/analytics/model/BaseModel.kt | 19 +- .../java/com/klaviyo/analytics/model/Event.kt | 4 + .../analytics/model/ImmutableProfile.kt | 17 + .../com/klaviyo/analytics/model/Profile.kt | 26 +- .../com/klaviyo/analytics/model/ProfileKey.kt | 40 +- .../klaviyo/analytics/networking/ApiClient.kt | 8 + .../analytics/networking/KlaviyoApiClient.kt | 5 + .../networking/requests/ApiRequest.kt | 5 + .../analytics/networking/requests/JSONUtil.kt | 12 + .../networking/requests/KlaviyoApiRequest.kt | 27 +- .../requests/KlaviyoApiRequestDecoder.kt | 4 + .../requests/KlaviyoErrorResponse.kt | 39 ++ .../requests/KlaviyoErrorResponseDecoder.kt | 40 ++ .../requests/PushTokenApiRequest.kt | 6 +- .../requests/UnregisterPushTokenApiRequest.kt | 68 ++++ .../klaviyo/analytics/state/KlaviyoState.kt | 185 ++++++++++ .../state/PersistentObservableProfile.kt | 51 +++ .../state/PersistentObservableProperty.kt | 99 +++++ .../state/PersistentObservableString.kt | 36 ++ .../java/com/klaviyo/analytics/state/State.kt | 57 +++ .../analytics/state/StateSideEffects.kt | 169 +++++++++ .../klaviyo/analytics/DevicePropertiesTest.kt | 4 +- .../analytics/KlaviyoPreInitializeTest.kt | 23 +- .../java/com/klaviyo/analytics/KlaviyoTest.kt | 310 ++++++++++------ .../analytics/KlaviyoUninitializedTest.kt | 8 +- .../com/klaviyo/analytics/UserInfoTest.kt | 100 ----- .../klaviyo/analytics/model/KeywordsTest.kt | 15 +- .../com/klaviyo/analytics/model/ModelTests.kt | 20 + .../networking/KlaviyoApiClientTest.kt | 148 +++++--- .../networking/requests/BaseApiRequestTest.kt | 2 +- .../requests/KlaviyoApiRequestTest.kt | 155 +++++++- .../KlaviyoErrorResponseDecoderTest.kt | 142 ++++++++ .../requests/PushTokenApiRequestTest.kt | 17 + .../UnregisterPushTokenApiRequestTest.kt | 79 ++++ .../analytics/state/KlaviyoStateTest.kt | 253 +++++++++++++ .../state/PersistentObservableProfileTest.kt | 129 +++++++ .../state/PersistentObservableStringTest.kt | 158 ++++++++ .../analytics/state/StateSideEffectsTest.kt | 343 ++++++++++++++++++ sdk/build.gradle | 8 +- sdk/core/build.gradle | 3 +- .../src/main/java/com/klaviyo/core/KLog.kt | 108 +++--- .../main/java/com/klaviyo/core/Registry.kt | 33 +- .../java/com/klaviyo/core/config/Clock.kt | 2 +- .../java/com/klaviyo/core/config/Config.kt | 4 + .../com/klaviyo/core/config/KlaviyoConfig.kt | 29 +- .../com/klaviyo/core/config/SystemClock.kt | 2 + .../core/lifecycle/KlaviyoLifecycleMonitor.kt | 5 +- .../core/lifecycle/NoOpLifecycleCallbacks.kt | 21 -- .../core/model/SharedPreferencesDataStore.kt | 5 +- .../core/networking/KlaviyoNetworkMonitor.kt | 14 +- sdk/core/src/main/res/values/strings.xml | 6 + .../test/java/com/klaviyo/core/KLogTest.kt | 20 +- .../klaviyo/core/config/KlaviyoConfigTest.kt | 34 +- .../lifecycle/KlaviyoLifecycleMonitorTest.kt | 14 +- .../model/SharedPreferencesDataStoreTest.kt | 16 +- .../networking/KlaviyoNetworkMonitorTest.kt | 11 +- .../java/com/klaviyo/fixtures/BaseTest.kt | 33 +- sdk/push-fcm/build.gradle | 3 +- sdk/push-fcm/consumer-rules.pro | 2 + .../klaviyo/pushFcm/KlaviyoNotification.kt | 8 +- .../klaviyo/pushFcm/KlaviyoRemoteMessage.kt | 5 + versions.properties | 6 +- 73 files changed, 2811 insertions(+), 849 deletions(-) create mode 100644 sdk/analytics/consumer-rules.pro delete mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/ProfileOperationQueue.kt delete mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/UserInfo.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/model/ImmutableProfile.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/JSONUtil.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponse.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoder.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequest.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProfile.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProperty.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableString.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/state/State.kt create mode 100644 sdk/analytics/src/main/java/com/klaviyo/analytics/state/StateSideEffects.kt delete mode 100644 sdk/analytics/src/test/java/com/klaviyo/analytics/UserInfoTest.kt create mode 100644 sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoderTest.kt create mode 100644 sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequestTest.kt create mode 100644 sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt create mode 100644 sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableProfileTest.kt create mode 100644 sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableStringTest.kt create mode 100644 sdk/analytics/src/test/java/com/klaviyo/analytics/state/StateSideEffectsTest.kt delete mode 100644 sdk/core/src/main/java/com/klaviyo/core/lifecycle/NoOpLifecycleCallbacks.kt create mode 100644 sdk/core/src/main/res/values/strings.xml create mode 100644 sdk/push-fcm/consumer-rules.pro diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 11189e984..2a619178c 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -2,6 +2,19 @@ This document provides guidance on how to migrate from the old version of the SDK to a newer version. It will be updated as new versions are released including deprecations or breaking changes. +# 3.0.0 + +### Improvements +- The Klaviyo Android SDK now automatically tracks changes to the + user's notification permission whenever the app is opened or resumed. +- Additionally, the SDK will now hold the push token internally after you `resetProfile` + and automatically attach the token to the next profile. This is a change from past behavior where the token + would need to be explicitly set again after resetting. + +### Breaking Changes +- The `ProfileKey` options deprecated in `2.3.0` have been removed +- `Klaviyo.lifecycleCallbacks`, deprecated in `2.1.0` has been removed + ## 2.3.0 Deprecations #### Deprecated `ProfileKey` objects pertaining to identifiers The following `ProfileKey` objects have been deprecated in favor of using the explicit diff --git a/README.md b/README.md index 013a967e4..2b34e4e51 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ send them timely push notifications via [FCM (Firebase Cloud Messaging)](https:/ ```kotlin // build.gradle.kts dependencies { - implementation("com.github.klaviyo.klaviyo-android-sdk:analytics:2.4.1") - implementation("com.github.klaviyo.klaviyo-android-sdk:push-fcm:2.4.1") + implementation("com.github.klaviyo.klaviyo-android-sdk:analytics:3.0.0") + implementation("com.github.klaviyo.klaviyo-android-sdk:push-fcm:3.0.0") } ``` @@ -67,8 +67,8 @@ send them timely push notifications via [FCM (Firebase Cloud Messaging)](https:/ ```groovy // build.gradle dependencies { - implementation "com.github.klaviyo.klaviyo-android-sdk:analytics:2.4.1" - implementation "com.github.klaviyo.klaviyo-android-sdk:push-fcm:2.4.1" + implementation "com.github.klaviyo.klaviyo-android-sdk:analytics:3.0.0" + implementation "com.github.klaviyo.klaviyo-android-sdk:push-fcm:3.0.0" } ``` @@ -235,7 +235,7 @@ In order to send push notifications to your users, you must collect their push t This is done via the `Klaviyo.setPushToken` method, which registers push token and current authorization state via the [Create Client Push Token API](https://developers.klaviyo.com/en/reference/create_client_push_token). Once registered in your manifest, `KlaviyoPushService` will receive *new* push tokens via the `onNewToken` method. -We also recommend retrieving the current token on app startup and registering it with Klaviyo SDK. +We also recommend retrieving the latest token value on app startup and registering it with Klaviyo SDK. Add the following to your `Application.onCreate` method. ```kotlin @@ -249,6 +249,9 @@ override fun onCreate(savedInstanceState: Bundle?) { } ``` +*As of version 3.0.0*: After setting a push token, the Klaviyo SDK will automatically track changes to +the user's notification permission whenever the application is opened or resumed from the background. + **Reminder**: `Klaviyo.initialize` is required before using any other Klaviyo SDK functionality, even if you are only using the SDK for push notifications and not analytics. @@ -261,9 +264,9 @@ if you are only using the SDK for push notifications and not analytics. provide code examples for requesting permission and handling the user's response. #### Push tokens and multiple profiles -Klaviyo SDK will disassociate the device push token from the current profile whenever it is reset by calling -`setProfile` or `resetProfile`. You should call `setPushToken` again after resetting the currently tracked profile -to explicitly associate the device token to the new profile. +If a new profile was set using `setProfile` or if `resetProfile` was called and a new anonymous +profile was created, the push token will be automatically associated with the new profile without +any additional action (like setting token again) required. This functionality was added in release `3.0.0`. ### Receiving Push Notifications `KlaviyoPushService` will handle displaying all notifications via the `onMessageReceived` method regardless of @@ -487,6 +490,17 @@ the following metadata tag to your manifest file. ``` +#### Proguard / R8 Issues + +If you notice issues in the release build of your apps, you can try to manually add a couple rules +to your `proguard-rules.pro` to prevent obfuscation: +``` +-keep class com.klaviyo.analytics.** { *; } +-keep class com.klaviyo.core.** { *; } +-keep class com.klaviyo.push-fcm.** { *; } +``` + + ## Contributing See the [contributing guide](.github/CONTRIBUTING.md) to learn how to contribute to the Klaviyo Android SDK. We welcome your feedback in the [issues](https://github.com/klaviyo/klaviyo-android-sdk/issues) section of our public GitHub repository. diff --git a/build.gradle b/build.gradle index eebe727d3..b239c91c2 100644 --- a/build.gradle +++ b/build.gradle @@ -38,8 +38,33 @@ tasks.register("clean", Delete) { delete rootProject.layout.buildDirectory } +static def readXmlValue(String filePath, String tagName, Project project) { + def xmlFile = new File(project.projectDir, filePath) + // Check if the file exists + if (!xmlFile.exists()) { + throw new FileNotFoundException("The XML file does not exist: ${xmlFile.absolutePath}") + } + + // Parse the XML file + def xmlContent + try { + xmlContent = new XmlSlurper().parse(xmlFile) + } catch (Exception e) { + throw new RuntimeException("Failed to parse XML file: ${xmlFile.absolutePath}. Error: ${e.message}", e) + } + + // Look for the string with the specific name attribute + def result = xmlContent.'string'.find { it.@name == tagName } + + if (result == null) { + throw new IllegalArgumentException("No string found with the name '${tagName}' in the file: ${xmlFile.absolutePath}") + } + + return result.text() +} + dokkaHtmlMultiModule { - def versionName = versionFor(project, "version.klaviyo.versionName") as String + def versionName = readXmlValue('sdk/core/src/main/res/values/strings.xml','klaviyo_sdk_version_override', project) def oldVersionsDir = layout.buildDirectory.dir("../docs/") outputDirectory = layout.buildDirectory.dir("../docs/${versionName}") includes.from("README.md") @@ -73,9 +98,18 @@ public class BumpVersion extends DefaultTask { return nextVersion; } + static String readXmlValue(String filePath, String tagName) { + def xmlFile = new File(filePath) + def xmlContent = new XmlSlurper().parse(xmlFile) + // Look for the string with the specific name attribute + def result = xmlContent.'string'.find { it.@name == tagName } + return result?.text() ?: "" + } + @TaskAction public void bumpVersion() { - def currentVersion = versionFor(project, "version.klaviyo.versionName") as String + def currentVersion = readXmlValue('sdk/core/src/main/res/values/strings.xml','klaviyo_sdk_version_override') + println(currentVersion) def nextVersion = this.getNextVersion() def currentBuild = versionFor(project, "version.klaviyo.versionCode") as Integer def nextBuild = currentBuild + 1 @@ -83,7 +117,9 @@ public class BumpVersion extends DefaultTask { print("Auto-incrementing version code from $currentBuild to $nextBuild\n") ant.replace(file:"versions.properties", token:"versionCode=$currentBuild", value:"versionCode=$nextBuild") - ant.replace(file:"versions.properties", token:"versionName=$currentVersion", value:"versionName=$nextVersion") + def file = new File('sdk/core/src/main/res/values/strings.xml') + def newName = file.text.replace(currentVersion,nextVersion) + file.text = newName ant.replace(file:"README.md", token:"analytics:$currentVersion", value:"analytics:$nextVersion") ant.replace(file:"README.md", token:"push-fcm:$currentVersion", value:"push-fcm:$nextVersion") ant.replace(file:"docs/index.html", token:"$currentVersion", value:"$nextVersion") diff --git a/docs/index.html b/docs/index.html index 6ac8aaac9..f63ac5344 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,2 +1,2 @@ - + diff --git a/gradle.properties b/gradle.properties index 47ac9f89b..536f1ebc8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,5 +26,8 @@ android.enableJetifier=true # URL to Klaviyo server klaviyoServerUrl=https://a.klaviyo.com +# Klaviyo API Revision +klaviyoApiRevision=2023-07-15 + # Group ID prefix for all our published modules klaviyoGroupId=com.klaviyo diff --git a/sdk/analytics/build.gradle b/sdk/analytics/build.gradle index 5e925e554..f32487bcf 100644 --- a/sdk/analytics/build.gradle +++ b/sdk/analytics/build.gradle @@ -1,4 +1,3 @@ -import static de.fayard.refreshVersions.core.Versions.versionFor project.description = "Public analytics API functionality for the Klaviyo SDK suite" evaluationDependsOn(":sdk") @@ -30,7 +29,7 @@ afterEvaluate { from components[ext.publishBuildVariant] groupId = klaviyoGroupId artifactId = "analytics" - version = versionFor(project, "version.klaviyo.versionName") + version = readXmlValue('src/main/res/values/strings.xml','klaviyo_sdk_version_override', project(":sdk:core")) } } } diff --git a/sdk/analytics/consumer-rules.pro b/sdk/analytics/consumer-rules.pro new file mode 100644 index 000000000..47efa53bb --- /dev/null +++ b/sdk/analytics/consumer-rules.pro @@ -0,0 +1 @@ +-keep class com.klaviyo.core.** { *; } \ No newline at end of file diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/DeviceProperties.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/DeviceProperties.kt index 3a3990c27..ab5795799 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/DeviceProperties.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/DeviceProperties.kt @@ -5,8 +5,8 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.os.Build import androidx.core.app.NotificationManagerCompat -import com.klaviyo.core.BuildConfig import com.klaviyo.core.Registry +import com.klaviyo.core.config.KlaviyoConfig import com.klaviyo.core.config.getPackageInfoCompat import com.klaviyo.core.model.fetchOrCreate import java.util.UUID @@ -47,19 +47,17 @@ internal object DeviceProperties { packageInfo.getVersionCodeCompat().toString() } - val sdkVersion: String by lazy { - BuildConfig.VERSION - } + val sdkVersion: String + get() = KlaviyoConfig.sdkVersion - val sdkName: String by lazy { - "android" - } + val sdkName: String + get() = KlaviyoConfig.sdkName - val backgroundData: Boolean by lazy { - activityManager.isBackgroundRestrictedCompat() + val backgroundDataEnabled: Boolean by lazy { + !activityManager.isBackgroundRestrictedCompat() } - val notificationPermission: Boolean + val notificationPermissionGranted: Boolean get() = NotificationManagerCompat.from(Registry.config.applicationContext).areNotificationsEnabled() val applicationId: String by lazy { diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt index 983e27f0e..c76af4e3f 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt @@ -1,24 +1,25 @@ package com.klaviyo.analytics import android.app.Application -import android.app.Application.ActivityLifecycleCallbacks import android.content.Context import android.content.Intent import com.klaviyo.analytics.model.Event import com.klaviyo.analytics.model.EventKey import com.klaviyo.analytics.model.EventMetric -import com.klaviyo.analytics.model.PROFILE_IDENTIFIERS import com.klaviyo.analytics.model.Profile import com.klaviyo.analytics.model.ProfileKey import com.klaviyo.analytics.networking.ApiClient import com.klaviyo.analytics.networking.KlaviyoApiClient +import com.klaviyo.analytics.state.KlaviyoState +import com.klaviyo.analytics.state.State +import com.klaviyo.analytics.state.StateSideEffects import com.klaviyo.core.Operation import com.klaviyo.core.Registry import com.klaviyo.core.config.Config import com.klaviyo.core.config.LifecycleException -import com.klaviyo.core.lifecycle.NoOpLifecycleCallbacks import com.klaviyo.core.safeApply import com.klaviyo.core.safeCall +import java.io.Serializable import java.util.LinkedList import java.util.Queue @@ -34,17 +35,6 @@ object Klaviyo { */ private val preInitQueue: Queue> = LinkedList() - @Deprecated( - """ - Lifecycle callbacks are now handled internally by Klaviyo.initialize. - This property will be removed in the next major version. - """, - ReplaceWith("", "") - ) - val lifecycleCallbacks: ActivityLifecycleCallbacks get() = NoOpLifecycleCallbacks - - private val profileOperationQueue = ProfileOperationQueue() - init { /** * Since analytics module owns ApiClient, we must register it. @@ -76,10 +66,14 @@ object Klaviyo { registerActivityLifecycleCallbacks(Registry.lifecycleCallbacks) } ?: throw LifecycleException() - UserInfo.startObservers() - Registry.get().startService() + Registry.register(KlaviyoState()) + Registry.getOrNull()?.detach() + Registry.register(StateSideEffects()) + + Registry.get().apiKey = apiKey + if (preInitQueue.isNotEmpty()) { Registry.log.info( "Replaying ${preInitQueue.count()} operation(s) invoked prior to Klaviyo initialization." @@ -104,35 +98,7 @@ object Klaviyo { * @return Returns [Klaviyo] for call chaining */ fun setProfile(profile: Profile): Klaviyo = safeApply { - if (UserInfo.isIdentified) { - // If a profile with external identifiers is already in state, we must reset. - // This conditional is important to preserve merging with an anonymous profile. - resetProfile() - } - - // Copy the profile object, so we aren't mutating the argument - val mutableProfile = Profile().merge(profile) - - // Route identifiers to the explicit setter functions to re-use that validator logic - mutableProfile.externalId?.let { - setExternalId(it) - mutableProfile.externalId = null - } - - mutableProfile.email?.let { - setEmail(it) - mutableProfile.email = null - } - - mutableProfile.phoneNumber?.let { - setPhoneNumber(it) - mutableProfile.phoneNumber = null - } - - // Enqueue any remaining profile attributes - if (mutableProfile.propertyCount() > 0) { - profileOperationQueue.debounceProfileUpdate(mutableProfile) - } + Registry.get().setProfile(profile) } /** @@ -156,7 +122,7 @@ object Klaviyo { /** * @return The email of the currently tracked profile, if set */ - fun getEmail(): String? = safeCall { UserInfo.email.ifEmpty { null } } + fun getEmail(): String? = safeCall { Registry.get().email } /** * Assigns a phone number to the currently tracked Klaviyo profile @@ -183,9 +149,7 @@ object Klaviyo { /** * @return The phone number of the currently tracked profile, if set */ - fun getPhoneNumber(): String? = safeCall { - UserInfo.phoneNumber.ifEmpty { null } - } + fun getPhoneNumber(): String? = safeCall { Registry.get().phoneNumber } /** * Assigns a unique identifier to associate the currently tracked Klaviyo profile @@ -213,9 +177,7 @@ object Klaviyo { /** * @return The external ID of the currently tracked profile, if set */ - fun getExternalId(): String? = safeCall { - UserInfo.externalId.ifEmpty { null } - } + fun getExternalId(): String? = safeCall { Registry.get().externalId } /** * Saves a push token and registers to the current profile @@ -227,18 +189,12 @@ object Klaviyo { * * @param pushToken The push token provided by the device push service */ - fun setPushToken(pushToken: String) = safeApply { - UserInfo.setPushToken(pushToken) { - Registry.get().enqueuePushToken(pushToken, UserInfo.getAsProfile()) - } - } + fun setPushToken(pushToken: String) = safeApply { Registry.get().pushToken = pushToken } /** * @return The device push token, if one has been assigned to currently tracked profile */ - fun getPushToken(): String? = safeCall { - UserInfo.pushToken.ifEmpty { null } - } + fun getPushToken(): String? = safeCall { Registry.get().pushToken } /** * Assign an attribute to the currently tracked profile by key/value pair @@ -253,61 +209,30 @@ object Klaviyo { * @param value * @return Returns [Klaviyo] for call chaining */ - fun setProfileAttribute(propertyKey: ProfileKey, value: String): Klaviyo = safeApply { - if (PROFILE_IDENTIFIERS.contains(propertyKey)) { - value.trim().ifEmpty { - Registry.log.warning( - "Empty string for $propertyKey will be ignored. To clear identifiers use resetProfile." - ) - null - }?.also { validatedIdentifier -> - var property by when (propertyKey) { - ProfileKey.EXTERNAL_ID -> UserInfo::externalId - ProfileKey.EMAIL -> UserInfo::email - ProfileKey.PHONE_NUMBER -> UserInfo::phoneNumber - else -> return@safeApply - } - - if (property != validatedIdentifier) { - property = validatedIdentifier - profileOperationQueue.debounceProfileUpdate(UserInfo.getAsProfile()) - } else { - Registry.log.info( - "$propertyKey value was unchanged, the update will be ignored." - ) - } - } - } else { - profileOperationQueue.debounceProfileUpdate(Profile(mapOf(propertyKey to value))) - } + fun setProfileAttribute(propertyKey: ProfileKey, value: Serializable): Klaviyo = safeApply { + Registry.get().setAttribute(propertyKey, value) } /** * Clears all stored profile identifiers (e.g. email or phone) and starts a new tracked profile * - * NOTE: if a push token was registered to the current profile, you will need to - * call `setPushToken` again to associate this device to a new profile - * * This should be called whenever an active user in your app is removed * (e.g. after a logout) */ - fun resetProfile() = safeApply { - // Flush any pending profile changes immediately - profileOperationQueue.flushProfile() - - // Clear profile identifiers from state - UserInfo.reset() - } + fun resetProfile() = safeApply { Registry.get().reset() } /** * Creates an [Event] associated with the currently tracked profile * + * While it is preferable to [initialize] before interacting with the Klaviyo SDK, + * due to timing issues on some platforms, events are stored in an in-memory buffer prior to initialization, + * and will be replayed once you initialize with your public API key. + * * @param event A map-like object representing the event attributes * @return Returns [Klaviyo] for call chaining */ fun createEvent(event: Event): Klaviyo = safeApply { - Registry.log.verbose("Enqueuing ${event.metric.name} event") - Registry.get().enqueueEvent(event, UserInfo.getAsProfile()) + Registry.get().enqueueEvent(event, Registry.get().getAsProfile()) } /** @@ -326,6 +251,10 @@ object Klaviyo { * From an opened push Intent, creates an [EventMetric.OPENED_PUSH] [Event] * containing appropriate tracking parameters * + * While it is preferable to [initialize] before interacting with the Klaviyo SDK, + * due to timing issues on some platforms, events are stored in an in-memory buffer prior to initialization, + * and will be replayed once you initialize with your public API key. + * * @param intent the [Intent] from opening a notification */ fun handlePush(intent: Intent?) = safeApply(preInitQueue) { @@ -344,15 +273,16 @@ object Klaviyo { } } - UserInfo.pushToken.let { event[EventKey.PUSH_TOKEN] = it } + Registry.get().pushToken?.let { event[EventKey.PUSH_TOKEN] = it } Registry.log.verbose("Enqueuing ${event.metric.name} event") - Registry.get().enqueueEvent(event, UserInfo.getAsProfile()) + Registry.get().enqueueEvent(event, Registry.get().getAsProfile()) } /** * Checks whether a notification intent originated from Klaviyo */ + @Suppress("MemberVisibilityCanBePrivate") val Intent.isKlaviyoIntent: Boolean get() = this.getStringExtra("com.klaviyo._k")?.isNotEmpty() ?: false } diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/ProfileOperationQueue.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/ProfileOperationQueue.kt deleted file mode 100644 index 1182aa7a9..000000000 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/ProfileOperationQueue.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.klaviyo.analytics - -import com.klaviyo.analytics.model.Profile -import com.klaviyo.analytics.networking.ApiClient -import com.klaviyo.core.Registry -import com.klaviyo.core.config.Clock - -internal class ProfileOperationQueue { - /** - * Debounce timer for enqueuing profile API calls - */ - private var timer: Clock.Cancellable? = null - - /** - * Pending batch of profile updates to be merged into one API call - */ - private var pendingProfile: Profile? = null - - /** - * Uses debounce mechanism to merge profile changes - * within a short span of time into one API transaction - * - * @param profile Incoming profile attribute changes - */ - fun debounceProfileUpdate(profile: Profile) { - // Log for traceability - val operation = pendingProfile?.let { "Merging" } ?: "Starting" - Registry.log.verbose("$operation profile update") - - // Merge new changes into pending transaction and - // add current identifiers from UserInfo to pending transaction - pendingProfile = UserInfo.getAsProfile().merge( - pendingProfile?.merge(profile) ?: profile - ) - - // Reset timer - timer?.cancel() - timer = Registry.clock.schedule(Registry.config.debounceInterval.toLong()) { - flushProfile() - } - } - - /** - * Enqueue pending profile changes as an API call and then clear slate - */ - fun flushProfile() = pendingProfile?.let { - timer?.cancel() - Registry.log.verbose("Flushing profile update") - Registry.get().enqueueProfile(it) - pendingProfile = null - } -} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/UserInfo.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/UserInfo.kt deleted file mode 100644 index f83a8f76e..000000000 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/UserInfo.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.klaviyo.analytics - -import com.klaviyo.analytics.model.Profile -import com.klaviyo.analytics.model.ProfileKey -import com.klaviyo.analytics.model.ProfileKey.ANONYMOUS_ID -import com.klaviyo.analytics.model.ProfileKey.EMAIL -import com.klaviyo.analytics.model.ProfileKey.EXTERNAL_ID -import com.klaviyo.analytics.model.ProfileKey.PHONE_NUMBER -import com.klaviyo.analytics.model.ProfileKey.PUSH_STATE -import com.klaviyo.analytics.model.ProfileKey.PUSH_TOKEN -import com.klaviyo.analytics.networking.ApiClient -import com.klaviyo.analytics.networking.requests.KlaviyoApiRequest.Status.Failed -import com.klaviyo.analytics.networking.requests.PushTokenApiRequest -import com.klaviyo.core.Registry -import java.util.UUID - -/** - * Stores information on the currently active user - */ -internal object UserInfo { - - fun startObservers() { - Registry.get().onApiRequest { request -> - // If push token request totally fails, we must remove it from state - if (request is PushTokenApiRequest && request.status == Failed) { - pushState = "" - } - } - } - - /** - * Save or clear an identifier in the persistent store and return it - * - * @param key - * @param value - * @return - */ - private fun persist(key: ProfileKey, value: String): String = if (value.isEmpty()) { - Registry.dataStore.clear(key.name) - } else { - Registry.dataStore.store(key.name, value) - }.let { value } - - /** - * Get value from persistent store or return a fallback if it isn't present - * - * @param key - * @param fallback - * @return - */ - private fun fetch(key: ProfileKey, fallback: () -> String = { "" }): String = - Registry.dataStore.fetch(key.name).let { it.orEmpty().ifEmpty(fallback) } - - var externalId: String = "" - set(value) { field = persist(EXTERNAL_ID, value) } - get() = field.ifEmpty { fetch(EXTERNAL_ID).also { field = it } } - - var email: String = "" - set(value) { field = persist(EMAIL, value) } - get() = field.ifEmpty { fetch(EMAIL).also { field = it } } - - var phoneNumber: String = "" - set(value) { field = persist(PHONE_NUMBER, value) } - get() = field.ifEmpty { fetch(PHONE_NUMBER).also { field = it } } - - /** - * Anonymous ID is only used internally - * On first read, check for UUID in data store - * If not found, generate a fresh one and persist that - */ - var anonymousId: String = "" - private set(value) { field = persist(ANONYMOUS_ID, value) } - get() = field.ifEmpty { fetch(ANONYMOUS_ID, ::generateUuid).also { anonymousId = it } } - - var pushToken: String = "" - private set(value) { field = persist(PUSH_TOKEN, value) } - get() = field.ifEmpty { fetch(PUSH_TOKEN).also { field = it } } - - /** - * Track the most recent state of push token + device metadata sent to the backend API - */ - private var pushState: String = "" - set(value) { field = persist(PUSH_STATE, value) } - get() = field.ifEmpty { fetch(PUSH_STATE).also { field = it } } - - /** - * Save push token string to state - * If push token or any other device metadata have changed, - * invoke the onChanged callback (i.e. to enqueue the API request) - */ - fun setPushToken(token: String, onChanged: () -> Unit) { - // Use the request body format as our state tracking value - val newPushState = PushTokenApiRequest(token, getAsProfile()).requestBody - - if (newPushState != pushState) { - // Optimistic update algorithm: expect request to get to backend, - // on failure reset push state (see initializer). The main advantage to - // this algorithm is it prevents queueing duplicate requests immediately - pushState = newPushState ?: "" - pushToken = token - onChanged() - } - } - - /** - * Generate a new UUID for anonymous ID - */ - private fun generateUuid() = UUID.randomUUID().toString() - - /** - * Indicate whether we currently have externally-set profile identifiers - */ - val isIdentified get() = (externalId.isNotEmpty() || email.isNotEmpty() || phoneNumber.isNotEmpty()) - - /** - * Reset all user identifiers to defaults - * which will cause a new anonymous ID to be generated - */ - fun reset() = apply { - Registry.log.verbose("Resetting profile") - externalId = "" - email = "" - phoneNumber = "" - anonymousId = "" - pushToken = "" - pushState = "" - } - - /** - * Get the current [UserInfo] as a [Profile] data structure - * - * @return - */ - fun getAsProfile(): Profile = Profile().also { profile -> - profile.setAnonymousId(anonymousId) - - if (externalId.isNotEmpty()) { - profile.setExternalId(externalId) - } - - if (email.isNotEmpty()) { - profile.setEmail(email) - } - - if (phoneNumber.isNotEmpty()) { - profile.setPhoneNumber(phoneNumber) - } - } -} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/BaseModel.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/BaseModel.kt index 298f169de..23159f5c5 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/BaseModel.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/BaseModel.kt @@ -1,6 +1,7 @@ package com.klaviyo.analytics.model import java.io.Serializable +import org.json.JSONObject /** * Abstract class that wraps around a map to control access to its contents. @@ -40,20 +41,30 @@ abstract class BaseModel(properties: Map?) * Adds a custom property to the map. * Custom attributes can define any key name that isn't already reserved by Klaviyo */ - abstract fun setProperty(key: Key, value: Serializable?): BaseModel + abstract fun setProperty(key: Key, value: Serializable?): Self /** * Add a custom property to the map. * Custom attributes can define any key name that isn't already reserved by Klaviyo */ - abstract fun setProperty(key: String, value: Serializable?): BaseModel + abstract fun setProperty(key: String, value: Serializable?): Self + + /** + * Create a copy of this model object + */ + abstract fun copy(): Self /** * Merges attributes from another object into this one * * @param other Second instance from which to merge properties */ - open fun merge(other: Self) = apply { - other.propertyMap.forEach { (k, v) -> setProperty(k, v) } + open fun merge(other: Self?) = apply { + other?.let { it.propertyMap.forEach { (k, v) -> setProperty(k, v) } } } + + /** + * Encode to JSON when representing this model as a string + */ + override fun toString(): String = JSONObject(toMap()).toString() } diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Event.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Event.kt index e2f5e8943..4856764ca 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Event.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Event.kt @@ -37,4 +37,8 @@ class Event(val metric: EventMetric, properties: Map?) : override fun setProperty(key: String, value: Serializable?) = setProperty(EventKey.CUSTOM(key), value) + + override fun copy(): Event = Event(metric).merge(this) + + override fun merge(other: Event?) = apply { super.merge(other) } } diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/ImmutableProfile.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/ImmutableProfile.kt new file mode 100644 index 000000000..a81cf600b --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/ImmutableProfile.kt @@ -0,0 +1,17 @@ +package com.klaviyo.analytics.model + +import java.io.Serializable + +/** + * Immutable implementation of [Profile] model to support observability and prevent untracked mutations + */ +interface ImmutableProfile { + val externalId: String? + val email: String? + val phoneNumber: String? + val anonymousId: String? + + operator fun get(key: ProfileKey): Serializable? + + fun copy(): Profile +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Profile.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Profile.kt index 76d70274c..b82cf9da0 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Profile.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/Profile.kt @@ -6,7 +6,7 @@ import java.io.Serializable * Controls the data that can be input into a map of profile attributes recognised by Klaviyo */ class Profile(properties: Map?) : - BaseModel(properties) { + BaseModel(properties), ImmutableProfile { constructor( externalId: String? = null, @@ -20,33 +20,43 @@ class Profile(properties: Map?) : } fun setExternalId(identifier: String?) = apply { this.externalId = identifier } - var externalId: String? + override var externalId: String? get() = (this[ProfileKey.EXTERNAL_ID])?.toString() set(value) { this[ProfileKey.EXTERNAL_ID] = value } fun setEmail(email: String?) = apply { this.email = email } - var email: String? + override var email: String? get() = (this[ProfileKey.EMAIL])?.toString() set(value) { this[ProfileKey.EMAIL] = value } fun setPhoneNumber(phoneNumber: String?) = apply { this.phoneNumber = phoneNumber } - var phoneNumber: String? + override var phoneNumber: String? get() = (this[ProfileKey.PHONE_NUMBER])?.toString() set(value) { this[ProfileKey.PHONE_NUMBER] = value } internal fun setAnonymousId(anonymousId: String?) = apply { this.anonymousId = anonymousId } - internal var anonymousId: String? + override var anonymousId: String? get() = (this[ProfileKey.ANONYMOUS_ID])?.toString() set(value) { this[ProfileKey.ANONYMOUS_ID] = value } + /** + * Return a profile object containing only non-identifier attributes + */ + val attributes: Profile get() = copy().apply { + externalId = null + email = null + phoneNumber = null + anonymousId = null + } + override fun setProperty(key: ProfileKey, value: Serializable?) = apply { this[key] = value } @@ -55,5 +65,9 @@ class Profile(properties: Map?) : this[ProfileKey.CUSTOM(key)] = value } - override fun merge(other: Profile) = apply { super.merge(other) } + override fun copy(): Profile = Profile().merge(this) + + override fun merge(other: Profile?) = apply { super.merge(other) } + + fun merge(other: ImmutableProfile?) = apply { super.merge(other?.copy()) } } diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/ProfileKey.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/ProfileKey.kt index 936904608..2f097c94f 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/model/ProfileKey.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/model/ProfileKey.kt @@ -7,20 +7,9 @@ package com.klaviyo.analytics.model sealed class ProfileKey(name: String) : Keyword(name) { // Identifiers - @Deprecated( - "The key for external_id will be made private in an upcoming release, use explicit setter instead." - ) - object EXTERNAL_ID : ProfileKey("external_id") - - @Deprecated( - "The key for email will be made private in an upcoming release, use explicit setter instead." - ) - object EMAIL : ProfileKey("email") - - @Deprecated( - "The key for phone_number will be made private in an upcoming release, use explicit setter instead." - ) - object PHONE_NUMBER : ProfileKey("phone_number") + internal object EXTERNAL_ID : ProfileKey("external_id") + internal object EMAIL : ProfileKey("email") + internal object PHONE_NUMBER : ProfileKey("phone_number") internal object ANONYMOUS_ID : ProfileKey("anonymous_id") internal object PUSH_TOKEN : ProfileKey("push_token") internal object PUSH_STATE : ProfileKey("push_state") @@ -32,6 +21,7 @@ sealed class ProfileKey(name: String) : Keyword(name) { object TITLE : ProfileKey("title") object IMAGE : ProfileKey("image") + // Location attributes object ADDRESS1 : ProfileKey("address1") object ADDRESS2 : ProfileKey("address2") object CITY : ProfileKey("city") @@ -44,24 +34,8 @@ sealed class ProfileKey(name: String) : Keyword(name) { // Custom properties class CUSTOM(propertyName: String) : ProfileKey(propertyName) - - /** - * Helper method to translate certain keys to their dollar-prefixed key - * This only applies to the identifier keys under certain circumstances - * - * @return - */ - internal fun specialKey(): String = when (this) { - ANONYMOUS_ID -> "\$anonymous" - EXTERNAL_ID -> "\$id" - EMAIL, PHONE_NUMBER -> "$$name" - else -> name - } } -internal val PROFILE_IDENTIFIERS = arrayOf( - ProfileKey.EXTERNAL_ID, - ProfileKey.EMAIL, - ProfileKey.PHONE_NUMBER, - ProfileKey.ANONYMOUS_ID -) +internal object PROFILE_ATTRIBUTES : Keyword("attributes") + +internal object API_KEY : Keyword("api_key") diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/ApiClient.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/ApiClient.kt index a4043fd07..b73fd6ea3 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/ApiClient.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/ApiClient.kt @@ -47,6 +47,14 @@ interface ApiClient { */ fun enqueuePushToken(token: String, profile: Profile) + /** + * Queue an API request to remove a push token from a [Profile] + * + * @param token + * @param profile + */ + fun enqueueUnregisterPushToken(apiKey: String, token: String, profile: Profile) + /** * Queue an API request to track an [Event] to Klaviyo for a [Profile] * diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/KlaviyoApiClient.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/KlaviyoApiClient.kt index e22a59914..0534f0ed2 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/KlaviyoApiClient.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/KlaviyoApiClient.kt @@ -12,6 +12,7 @@ import com.klaviyo.analytics.networking.requests.KlaviyoApiRequest.Status import com.klaviyo.analytics.networking.requests.KlaviyoApiRequestDecoder import com.klaviyo.analytics.networking.requests.ProfileApiRequest import com.klaviyo.analytics.networking.requests.PushTokenApiRequest +import com.klaviyo.analytics.networking.requests.UnregisterPushTokenApiRequest import com.klaviyo.core.Registry import com.klaviyo.core.lifecycle.ActivityEvent import java.util.Collections @@ -65,6 +66,10 @@ internal object KlaviyoApiClient : ApiClient { enqueueRequest(PushTokenApiRequest(token, profile)) } + override fun enqueueUnregisterPushToken(apiKey: String, token: String, profile: Profile) { + enqueueRequest(UnregisterPushTokenApiRequest(apiKey, token, profile)) + } + override fun enqueueEvent(event: Event, profile: Profile) { Registry.log.verbose("Enqueuing ${event.metric.name} event") enqueueRequest(EventApiRequest(event, profile)) diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/ApiRequest.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/ApiRequest.kt index 354507c51..a58fda7eb 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/ApiRequest.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/ApiRequest.kt @@ -88,4 +88,9 @@ interface ApiRequest { * @return */ val responseBody: String? + + /** + * Error messaging associated with the response + */ + val errorBody: KlaviyoErrorResponse } diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/JSONUtil.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/JSONUtil.kt new file mode 100644 index 000000000..0153e4d4c --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/JSONUtil.kt @@ -0,0 +1,12 @@ +package com.klaviyo.analytics.networking.requests + +import org.json.JSONObject + +internal object JSONUtil { + + /** + * Using this util since built-in optString gets scared with a null default value + */ + fun JSONObject.getStringNullable(key: String): String? = + if (has(key) && !isNull(key)) getString(key) else null +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequest.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequest.kt index 642242037..b736a9288 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequest.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequest.kt @@ -12,6 +12,7 @@ import javax.net.ssl.HttpsURLConnection import kotlin.math.max import kotlin.math.min import kotlin.math.pow +import org.json.JSONException import org.json.JSONObject /** @@ -45,6 +46,7 @@ internal open class KlaviyoApiRequest( const val PROFILE = "profile" const val EVENT = "event" const val PUSH_TOKEN = "push-token" + const val UNREGISTER_PUSH_TOKEN = "push-token-unregister" // Headers const val HEADER_CONTENT = "Content-Type" @@ -55,12 +57,13 @@ internal open class KlaviyoApiRequest( const val HEADER_KLAVIYO_ATTEMPT = "X-Klaviyo-Attempt-Count" const val HEADER_RETRY_AFTER = "Retry-After" const val TYPE_JSON = "application/json" - const val V3_REVISION = "2023-07-15" const val HTTP_OK = HttpURLConnection.HTTP_OK const val HTTP_ACCEPTED = HttpURLConnection.HTTP_ACCEPTED const val HTTP_MULT_CHOICE = HttpURLConnection.HTTP_MULT_CHOICE const val HTTP_RETRY = 429 // oddly not a const in HttpURLConnection + const val HTTP_UNAVAILABLE = HttpURLConnection.HTTP_UNAVAILABLE + const val HTTP_BAD_REQUEST = HttpURLConnection.HTTP_BAD_REQUEST // JSON keys for persistence const val TYPE_JSON_KEY = "request_type" @@ -169,7 +172,7 @@ internal open class KlaviyoApiRequest( override val headers: MutableMap = mutableMapOf( HEADER_CONTENT to TYPE_JSON, HEADER_ACCEPT to TYPE_JSON, - HEADER_REVISION to V3_REVISION, + HEADER_REVISION to Registry.config.apiRevision, HEADER_USER_AGENT to DeviceProperties.userAgent, HEADER_KLAVIYO_MOBILE to "1", HEADER_KLAVIYO_ATTEMPT to "$attempts/${Registry.config.networkMaxAttempts}" @@ -232,6 +235,22 @@ internal open class KlaviyoApiRequest( override var responseBody: String? = null protected set + /** + * Parsing the error response or creating an empty one if there is none + */ + override val errorBody: KlaviyoErrorResponse + by lazy { + responseBody?.let { body -> + val responseJson = try { + JSONObject(body) + } catch (e: JSONException) { + Registry.log.wtf("Malformed error response body from backend", e) + JSONObject() + } + KlaviyoErrorResponseDecoder.fromJson(responseJson) + } ?: KlaviyoErrorResponse(listOf()) + } + /** * Creates a representation of this [KlaviyoApiRequest] in JSON * @@ -342,7 +361,7 @@ internal open class KlaviyoApiRequest( status = when (responseCode) { in successCodes -> Status.Complete - HTTP_RETRY -> { + HTTP_RETRY, HTTP_UNAVAILABLE -> { if (attempts < Registry.config.networkMaxAttempts) { Status.PendingRetry } else { @@ -350,7 +369,7 @@ internal open class KlaviyoApiRequest( } } // TODO - Special handling of unauthorized i.e. 401 and 403? - // TODO - Special handling of server errors 500 and 503? + // TODO - Special handling of server error 500? else -> Status.Failed } diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestDecoder.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestDecoder.kt index d5b922fb8..1c1b2fd1a 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestDecoder.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestDecoder.kt @@ -24,6 +24,10 @@ internal object KlaviyoApiRequestDecoder { ProfileApiRequest::class.simpleName -> ProfileApiRequest(time, uuid) EventApiRequest::class.simpleName -> EventApiRequest(time, uuid) PushTokenApiRequest::class.simpleName -> PushTokenApiRequest(time, uuid) + UnregisterPushTokenApiRequest::class.simpleName -> UnregisterPushTokenApiRequest( + time, + uuid + ) else -> KlaviyoApiRequest(urlPath, method, time, uuid) }.apply { headers.replaceAllWith( diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponse.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponse.kt new file mode 100644 index 000000000..d8acdd02a --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponse.kt @@ -0,0 +1,39 @@ +package com.klaviyo.analytics.networking.requests + +/** + * For parsing Error responses from the backend when an HTTP request fails + */ + +data class KlaviyoErrorResponse( + val errors: List +) { + internal companion object { + // Error body constants + const val ERRORS = "errors" + const val ID = "id" + const val STATUS = "status" + const val TITLE = "title" + const val DETAIL = "detail" + const val SOURCE = "source" + const val POINTER = "pointer" + const val INVALID_INPUT_TITLE = "Invalid input." + } +} + +data class KlaviyoError( + val id: String? = null, + val status: Int? = null, + val title: String? = null, + val detail: String? = null, + val source: KlaviyoErrorSource? = null +) + +data class KlaviyoErrorSource( + val pointer: String? = null +) { + internal companion object { + // current path objects from the backend + const val EMAIL_PATH = "/data/attributes/email" + const val PHONE_NUMBER_PATH = "/data/attributes/phone_number" + } +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoder.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoder.kt new file mode 100644 index 000000000..7e9a50af1 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoder.kt @@ -0,0 +1,40 @@ +package com.klaviyo.analytics.networking.requests + +import com.klaviyo.analytics.networking.requests.JSONUtil.getStringNullable +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +internal object KlaviyoErrorResponseDecoder { + + /** + * Construct an Error Response from a JSON object + * + * @return ErrorResponse from the JSON + */ + internal fun fromJson(json: JSONObject): KlaviyoErrorResponse { + val errorsJsonArray: JSONArray = try { + json.getJSONArray(KlaviyoErrorResponse.ERRORS) + } catch (e: JSONException) { + JSONArray() + } + val errorsList = mutableListOf() + for (errorJsonIndex in 0 until errorsJsonArray.length()) { + val errorJson = errorsJsonArray.getJSONObject(errorJsonIndex) + errorsList.add( + KlaviyoError( + id = errorJson.getStringNullable(KlaviyoErrorResponse.ID), + status = errorJson.getInt(KlaviyoErrorResponse.STATUS), + title = errorJson.getStringNullable(KlaviyoErrorResponse.TITLE), + detail = errorJson.getStringNullable(KlaviyoErrorResponse.DETAIL), + source = errorJson.getJSONObject(KlaviyoErrorResponse.SOURCE)?.let { + KlaviyoErrorSource( + it.getStringNullable(KlaviyoErrorResponse.POINTER) + ) + } + ) + ) + } + return KlaviyoErrorResponse(errorsList) + } +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequest.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequest.kt index 3db4bb228..5b6ef9baa 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequest.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequest.kt @@ -66,11 +66,11 @@ internal class PushTokenApiRequest( optJSONObject(DATA)?.optJSONObject(ATTRIBUTES)?.apply { put( ENABLEMENT_STATUS, - if (DeviceProperties.notificationPermission) NOTIFICATIONS_ENABLED else NOTIFICATIONS_DISABLED + if (DeviceProperties.notificationPermissionGranted) NOTIFICATIONS_ENABLED else NOTIFICATIONS_DISABLED ) put( BACKGROUND, - if (DeviceProperties.backgroundData) BG_AVAILABLE else BG_UNAVAILABLE + if (DeviceProperties.backgroundDataEnabled) BG_AVAILABLE else BG_UNAVAILABLE ) put(METADATA, JSONObject(DeviceProperties.buildMetaData())) } @@ -78,7 +78,7 @@ internal class PushTokenApiRequest( override fun equals(other: Any?): Boolean { return when (other) { - is PushTokenApiRequest -> body.toString() == other.body.toString() + is PushTokenApiRequest -> body.toString() == other.body.toString() && query.toString() == other.query.toString() else -> super.equals(other) } } diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequest.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequest.kt new file mode 100644 index 000000000..230bef196 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequest.kt @@ -0,0 +1,68 @@ +package com.klaviyo.analytics.networking.requests + +import com.klaviyo.analytics.DeviceProperties +import com.klaviyo.analytics.model.Profile +import com.klaviyo.core.Registry + +/** + * Defines the content of an API request to remove a push token from a [Profile] + * + * @constructor + */ +internal class UnregisterPushTokenApiRequest : KlaviyoApiRequest { + + constructor(queuedTime: Long? = null, uuid: String? = null) : super( + PATH, + RequestMethod.POST, + queuedTime, + uuid + ) + + constructor(apiKey: String, token: String, profile: Profile) : this() { + body = jsonMapOf( + DATA to mapOf( + TYPE to UNREGISTER_PUSH_TOKEN, + ATTRIBUTES to filteredMapOf( + PROFILE to mapOf(*ProfileApiRequest.formatBody(profile)), + TOKEN to token, + PLATFORM to DeviceProperties.platform, + VENDOR to VENDOR_FCM + ) + ) + ) + query = mapOf( + COMPANY_ID to apiKey + ) + } + + private companion object { + const val PATH = "client/push-token-unregister" + const val TOKEN = "token" + const val PLATFORM = "platform" + + const val VENDOR = "vendor" + const val VENDOR_FCM = "FCM" + } + + override val type: String = "Unregister Push Token" + + /** + * HTTP request query params + */ + override var query: Map = mapOf( + COMPANY_ID to Registry.config.apiKey + ) + + override val successCodes: IntRange get() = HTTP_ACCEPTED..HTTP_ACCEPTED + + override fun equals(other: Any?): Boolean { + return when (other) { + is UnregisterPushTokenApiRequest -> body.toString() == other.body.toString() && query.toString() == other.query.toString() + else -> super.equals(other) + } + } + + override fun hashCode(): Int { + return body.toString().hashCode() + } +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt new file mode 100644 index 000000000..71f33a512 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt @@ -0,0 +1,185 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.API_KEY +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.analytics.model.PROFILE_ATTRIBUTES +import com.klaviyo.analytics.model.Profile +import com.klaviyo.analytics.model.ProfileKey +import com.klaviyo.analytics.model.ProfileKey.ANONYMOUS_ID +import com.klaviyo.analytics.model.ProfileKey.EMAIL +import com.klaviyo.analytics.model.ProfileKey.EXTERNAL_ID +import com.klaviyo.analytics.model.ProfileKey.PHONE_NUMBER +import com.klaviyo.analytics.model.ProfileKey.PUSH_STATE +import com.klaviyo.analytics.model.ProfileKey.PUSH_TOKEN +import com.klaviyo.analytics.networking.requests.PushTokenApiRequest +import com.klaviyo.core.Registry +import java.io.Serializable +import java.util.Collections +import java.util.UUID + +/** + * Stores information on the currently active user + */ +internal class KlaviyoState : State { + + private val _apiKey = PersistentObservableString(API_KEY, ::broadcastChange) + override var apiKey by _apiKey + + private val _externalId = PersistentObservableString(EXTERNAL_ID, ::broadcastChange) + override var externalId by _externalId + + private val _email = PersistentObservableString(EMAIL, ::broadcastChange) + override var email by _email + + private val _phoneNumber = PersistentObservableString(PHONE_NUMBER, ::broadcastChange) + override var phoneNumber by _phoneNumber + + private val _anonymousId = PersistentObservableString(ANONYMOUS_ID, ::broadcastChange) { + UUID.randomUUID().toString() + } + override val anonymousId by _anonymousId + + private val _attributes = PersistentObservableProfile(PROFILE_ATTRIBUTES, ::broadcastChange) + private var attributes by _attributes + + private val _pushState = PersistentObservableString(PUSH_STATE, ::broadcastChange) + override var pushState by _pushState + + private val _pushToken = PersistentObservableString(PUSH_TOKEN, ::broadcastChange) + override var pushToken: String? + set(value) { + // Set token should also update entire push state value + _pushToken.setValue(this, ::_pushToken, value) + pushState = value?.let { PushTokenApiRequest(it, getAsProfile()).requestBody } ?: "" + } + get() = _pushToken.getValue(this, ::_pushToken) + + /** + * List of registered state change observers + */ + private val stateObservers = Collections.synchronizedList( + mutableListOf() + ) + + /** + * Register an observer to be notified when state changes + * + * @param observer + */ + override fun onStateChange(observer: StateObserver) { + stateObservers += observer + } + + /** + * De-register an observer from [onStateChange] + * + * @param observer + */ + override fun offStateChange(observer: StateObserver) { + stateObservers -= observer + } + + /** + * Get all user data in state as a [Profile] model object + */ + override fun getAsProfile(withAttributes: Boolean): Profile = Profile( + externalId = externalId, + email = email, + phoneNumber = phoneNumber + ).also { profile -> + profile.setAnonymousId(anonymousId) + profile.takeIf { withAttributes }?.merge(attributes) + } + + /** + * Update user state from a new [Profile] model object + */ + override fun setProfile(profile: Profile) { + if (!externalId.isNullOrEmpty() || !email.isNullOrEmpty() || !phoneNumber.isNullOrEmpty()) { + // If a profile with external identifiers is already in state, we must reset. + // This conditional is important to preserve merging with an anonymous profile. + reset() + } + + // Move any identifiers and attributes to their specified state variables + this.externalId = profile.externalId + this.email = profile.email + this.phoneNumber = profile.phoneNumber + this.attributes = profile.attributes + } + + /** + * Set an individual property or attribute + */ + override fun setAttribute(key: ProfileKey, value: Serializable) = when (key) { + EMAIL -> (value as? String)?.let { email = it } ?: run { logCastError(EMAIL, value) } + EXTERNAL_ID -> (value as? String)?.let { externalId = it } ?: run { + logCastError( + EXTERNAL_ID, + value + ) + } + PHONE_NUMBER -> (value as? String)?.let { phoneNumber = it } ?: run { + logCastError( + PHONE_NUMBER, + value + ) + } + else -> this.attributes = (this.attributes?.copy() ?: Profile()).setProperty(key, value) + } + + private fun logCastError(key: ProfileKey, value: Serializable) { + Registry.log.error( + "Unable to cast value $value of type ${value::class.java} to String attribute ${key.name}" + ) + } + + /** + * Reset all user identifiers to defaults, clear custom profile attributes. + * A new anonymous ID will be generated next time it is accessed. + */ + override fun reset() { + val oldProfile = getAsProfile(true) + + _externalId.reset() + _email.reset() + _phoneNumber.reset() + _anonymousId.reset() + _attributes.reset() + + broadcastChange(null, oldProfile) + Registry.log.verbose("Reset internal user state") + } + + /** + * Clear user's attributes from internal state, leaving profile identifiers intact + */ + override fun resetAttributes() { + val oldAttributes = attributes?.copy() + _attributes.reset() + broadcastChange(PROFILE_ATTRIBUTES, oldAttributes) + } + + private fun broadcastChange(property: PersistentObservableProperty?, oldValue: T?) = + broadcastChange(property?.key, oldValue) + + private fun broadcastChange(key: Keyword? = null, oldValue: Any? = null) { + synchronized(stateObservers) { + stateObservers.forEach { it(key, oldValue) } + } + } + + /** + * For resetting user email field after an invalid input response + */ + internal fun resetEmail() { + _email.reset() + } + + /** + * For resetting user email field after an invalid input response + */ + internal fun resetPhoneNumber() { + _phoneNumber.reset() + } +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProfile.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProfile.kt new file mode 100644 index 000000000..4bbc2a816 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProfile.kt @@ -0,0 +1,51 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.ImmutableProfile +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.analytics.model.Profile +import com.klaviyo.core.Registry +import java.io.Serializable +import org.json.JSONArray +import org.json.JSONObject + +internal class PersistentObservableProfile( + key: Keyword, + onChanged: PropertyObserver = { _, _ -> } +) : PersistentObservableProperty( + key = key, + onChanged = onChanged +) { + + /** + * Decode a JSON string to [Profile] + */ + override fun deserialize(storedValue: String?): ImmutableProfile? = storedValue + ?.takeIf { it.isNotEmpty() } + ?.let { + try { + val json = JSONObject(storedValue) + Profile().also { profile -> + json.keys().forEach { key -> + profile[key] = deserializeValue(json.get(key)) + } + } + } catch (e: Throwable) { + Registry.log.warning("Invalid stored JSON for $key", e) + null + } + } + + /** + * Recursively decode JSON into [Serializable] for type-safety + * when re-populating a [Profile] + */ + private fun deserializeValue(v: Any): Serializable = when (v) { + is JSONArray -> Array(v.length()) { deserializeValue(v[it]) } + is JSONObject -> HashMap(v.length()).also { map -> + v.keys().forEach { key -> + map[key] = deserializeValue(v.get(key)) + } + } + else -> v as Serializable + } +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProperty.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProperty.kt new file mode 100644 index 000000000..6cfb14207 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProperty.kt @@ -0,0 +1,99 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.core.Registry +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +internal typealias PropertyObserver = (property: PersistentObservableProperty, oldValue: T?) -> Unit + +/** + * Property delegate that is backed by the persistent store. + * + * When set, the value will be persisted to [key], and + * on first get, [key] will be read from the store into memory. + * + * If no persistent value exists, the value provided by [fallback] will be + * read into memory and saved to disk. + * + * [T] Should implement [toString] for serializing to persistent store, + * and subclasses must implement [deserialize] for reading back. + * + * When the property's value changes, as detected by [validateChange], + * then [onChanged] is triggered. + * + * @see [kotlin.properties.ObservableProperty] Inspiration for this class, + * but it was simpler to re-implement, so we can access the private [value]. + */ +internal abstract class PersistentObservableProperty( + val key: Keyword, + private val fallback: () -> T? = { null }, + private val onChanged: PropertyObserver +) : ReadWriteProperty { + + /** + * Value of this property, backed by persistent store + */ + private var value: T? = null + get() = field ?: fetch()?.also { field = it } + set(newValue) { + field = newValue + persist(newValue) + } + + /** + * Public accessor to property's value + */ + override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value + + /** + * Set the value of this property if it passes validation rules from [validateChange] + */ + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + if (validateChange(this.value, value)) { + val oldValue = this.value + this.value = value + onChanged(this, oldValue) + } + } + + /** + * Reset the value to default in memory and on disk, + * bypassing validation and callbacks + */ + fun reset() { value = null } + + /** + * Triggered by [setValue] to validate a change. + * If this returns false, the property is not updated in memory or on disk. + */ + protected open fun validateChange(oldValue: T?, newValue: T?): Boolean = + if (oldValue == newValue) { + Registry.log.info("Ignored update for $key, value is unchanged") + false + } else { + true + } + + /** + * Deserialize from persistent store + */ + abstract fun deserialize(storedValue: String?): T? + + /** + * Save or clear property in the persistent store + */ + private fun persist(value: T?) = when (val serializedValue = value?.toString()) { + null -> Registry.dataStore.clear(key.name) + else -> Registry.dataStore.store(key.name, serializedValue) + } + + /** + * Get value from persistent store or return a fallback if it isn't present + * If fallback is invoked, save its return value to persistent store + * + * @return + */ + private fun fetch(): T? = Registry.dataStore.fetch(key.name)?.let(::deserialize) + ?: fallback()?.also(::persist) +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableString.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableString.kt new file mode 100644 index 000000000..436e827ba --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableString.kt @@ -0,0 +1,36 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.core.Registry +import kotlin.reflect.KProperty + +internal class PersistentObservableString( + key: Keyword, + onChanged: PropertyObserver = { _, _ -> }, + fallback: () -> String? = { null } +) : PersistentObservableProperty( + key = key, + fallback = fallback, + onChanged = onChanged +) { + override fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) { + val trimmedValue = value?.trim() + + if (trimmedValue != value) { + Registry.log.verbose("Trimmed whitespace from ${property.name}.") + } + + super.setValue(thisRef, property, trimmedValue) + } + + override fun validateChange(oldValue: String?, newValue: String?): Boolean { + if (newValue.isNullOrEmpty()) { + Registry.log.warning("Empty string value for $key will be ignored.") + return false + } + + return super.validateChange(oldValue, newValue) + } + + override fun deserialize(storedValue: String?): String? = storedValue +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/state/State.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/State.kt new file mode 100644 index 000000000..ef28a346f --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/State.kt @@ -0,0 +1,57 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.analytics.model.Profile +import com.klaviyo.analytics.model.ProfileKey +import java.io.Serializable + +typealias StateObserver = (key: Keyword?, oldValue: Any?) -> Unit + +interface State { + var apiKey: String? + var externalId: String? + var email: String? + var phoneNumber: String? + val anonymousId: String? + var pushToken: String? + var pushState: String? + + /** + * Register an observer to be notified when state changes + * + * @param observer + */ + fun onStateChange(observer: StateObserver) + + /** + * De-register an observer from [onStateChange] + * + * @param observer + */ + fun offStateChange(observer: StateObserver) + + /** + * Get all user data in state as a [Profile] model object + */ + fun getAsProfile(withAttributes: Boolean = false): Profile + + /** + * Update user state from a new [Profile] model object + */ + fun setProfile(profile: Profile) + + /** + * Set an individual attribute + */ + fun setAttribute(key: ProfileKey, value: Serializable) + + /** + * Remove all user identifiers and attributes from internal state + */ + fun reset() + + /** + * Clear user's attributes from internal state, leaving profile identifiers intact + */ + fun resetAttributes() +} diff --git a/sdk/analytics/src/main/java/com/klaviyo/analytics/state/StateSideEffects.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/StateSideEffects.kt new file mode 100644 index 000000000..c37871c97 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/StateSideEffects.kt @@ -0,0 +1,169 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.API_KEY +import com.klaviyo.analytics.model.ImmutableProfile +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.analytics.model.PROFILE_ATTRIBUTES +import com.klaviyo.analytics.model.Profile +import com.klaviyo.analytics.model.ProfileKey +import com.klaviyo.analytics.networking.ApiClient +import com.klaviyo.analytics.networking.requests.ApiRequest +import com.klaviyo.analytics.networking.requests.KlaviyoApiRequest +import com.klaviyo.analytics.networking.requests.KlaviyoApiRequest.Companion.HTTP_BAD_REQUEST +import com.klaviyo.analytics.networking.requests.KlaviyoErrorResponse +import com.klaviyo.analytics.networking.requests.KlaviyoErrorSource +import com.klaviyo.analytics.networking.requests.PushTokenApiRequest +import com.klaviyo.core.Registry +import com.klaviyo.core.config.Clock +import com.klaviyo.core.lifecycle.ActivityEvent +import com.klaviyo.core.lifecycle.LifecycleMonitor + +internal class StateSideEffects( + private val state: State = Registry.get(), + private val apiClient: ApiClient = Registry.get(), + private val lifecycleMonitor: LifecycleMonitor = Registry.lifecycleMonitor +) { + /** + * Debounce timer for enqueuing profile API calls + */ + private var timer: Clock.Cancellable? = null + + /** + * Pending batch of profile updates to be merged into one API call + */ + private var pendingProfile: ImmutableProfile? = null + + init { + apiClient.onApiRequest(false, ::afterApiRequest) + state.onStateChange(::onStateChange) + lifecycleMonitor.onActivityEvent(::onLifecycleEvent) + } + + /** + * Detach side effects observers + */ + fun detach() { + apiClient.offApiRequest(::afterApiRequest) + state.offStateChange(::onStateChange) + lifecycleMonitor.offActivityEvent(::onLifecycleEvent) + } + + private fun onPushStateChange() { + if (!state.pushState.isNullOrEmpty()) { + state.pushToken?.let { apiClient.enqueuePushToken(it, state.getAsProfile()) } + } + } + + private fun onApiKeyChange(oldApiKey: String?) { + // If the API key changes, we need to unregister the push token on the previous API key then register the push token with the new API key + if (!state.pushState.isNullOrEmpty()) { + state.pushToken?.let { + oldApiKey?.let { oldApiKey -> + apiClient.enqueueUnregisterPushToken(oldApiKey, it, state.getAsProfile()) + } + apiClient.enqueuePushToken(it, state.getAsProfile()) + } + } + } + + private fun onUserStateChange() { + val profile = state.getAsProfile(withAttributes = true) + + // Anonymous ID indicates a profile reset, we should flush any pending profile changes immediately + pendingProfile?.takeIf { it.anonymousId != profile.anonymousId }?.also { + flushProfile() + } + + Registry.log.verbose("${pendingProfile?.let { "Merging" } ?: "Starting"} profile update") + + // Merge changes into pending transaction, or start a new one + pendingProfile = pendingProfile?.copy()?.merge(profile) ?: profile + + // Reset timer + timer?.cancel() + timer = Registry.clock.schedule(Registry.config.debounceInterval.toLong()) { + flushProfile() + } + } + + /** + * Enqueue pending profile changes as an API call and then clear slate + */ + private fun flushProfile() = pendingProfile?.let { + timer?.cancel() + Registry.log.verbose("Flushing profile update") + enqueueTokenOrProfile(it.copy()) + state.resetAttributes() // Once captured in a request, we don't keep profile attributes in state/on disk + pendingProfile = null + } + + /** + * Enqueue pending profile changes as an API call to either push token endpoint or profile endpoint. + * + * Why? - Profile changes are sent to push token API when there is a push token present in state. + * This is done to avoid resetting the push token in state, making a profile request and then another + * request to the push token endpoint to set the push token. + * + * By just using the push token API we can avoid the extra request and also ensure that the push token + * is set on the new profile in Klaviyo. + */ + private fun enqueueTokenOrProfile(profile: Profile) { + state.pushToken?.let { + apiClient.enqueuePushToken(it, profile) + } ?: apiClient.enqueueProfile(profile) + } + + private fun afterApiRequest(request: ApiRequest) = when { + request.responseCode == HTTP_BAD_REQUEST -> { + request.errorBody.errors.find { it.title == KlaviyoErrorResponse.INVALID_INPUT_TITLE } + ?.let { inputError -> + when (inputError.source?.pointer) { + KlaviyoErrorSource.EMAIL_PATH -> { + (Registry.get() as? KlaviyoState)?.resetEmail().also { + Registry.log.warning( + "Invalid email - resetting email state to null" + ) + } + } + + KlaviyoErrorSource.PHONE_NUMBER_PATH -> { + (Registry.get() as? KlaviyoState)?.resetPhoneNumber().also { + Registry.log.warning( + "Invalid phone number - resetting phone number state to null" + ) + } + } + + else -> { + Registry.log.warning("Input error: ${inputError.detail}") + } + } + } + } + + request is PushTokenApiRequest && request.status == KlaviyoApiRequest.Status.Failed && request.responseCode != HTTP_BAD_REQUEST -> { + state.pushState = null + } + + else -> Unit + } + + private fun onStateChange(key: Keyword?, oldValue: Any?) = when (key) { + API_KEY -> onApiKeyChange(oldApiKey = oldValue?.toString()) + ProfileKey.PUSH_STATE -> onPushStateChange() + ProfileKey.PUSH_TOKEN -> { /* Token is a no-op, push changes are captured by push state */ + } + PROFILE_ATTRIBUTES -> if (state.getAsProfile(withAttributes = true).attributes.propertyCount() > 0) { + onUserStateChange() + } else { Unit } + else -> onUserStateChange() + } + + private fun onLifecycleEvent(activity: ActivityEvent): Unit = when { + activity is ActivityEvent.Resumed -> Registry.get().pushToken?.let { + // This should trigger the token in state to refresh overall push state + Registry.get().pushToken = it + } ?: Unit + else -> Unit + } +} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/DevicePropertiesTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/DevicePropertiesTest.kt index 3870a3b84..4eb930234 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/DevicePropertiesTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/DevicePropertiesTest.kt @@ -26,8 +26,8 @@ internal class DevicePropertiesTest : BaseTest() { every { DeviceProperties.appVersionCode } returns "Mock Version Code" every { DeviceProperties.sdkName } returns "Mock SDK" every { DeviceProperties.sdkVersion } returns "Mock SDK Version" - every { DeviceProperties.backgroundData } returns true - every { DeviceProperties.notificationPermission } returns true + every { DeviceProperties.backgroundDataEnabled } returns true + every { DeviceProperties.notificationPermissionGranted } returns true every { DeviceProperties.applicationId } returns "Mock App ID" every { DeviceProperties.platform } returns "Android" every { DeviceProperties.deviceId } returns "Mock Device ID" diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoPreInitializeTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoPreInitializeTest.kt index fd8a3c09f..19d48e5bb 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoPreInitializeTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoPreInitializeTest.kt @@ -3,13 +3,14 @@ package com.klaviyo.analytics import android.app.Application import com.klaviyo.analytics.model.EventMetric import com.klaviyo.analytics.networking.ApiClient +import com.klaviyo.analytics.state.State +import com.klaviyo.analytics.state.StateSideEffects import com.klaviyo.core.MissingConfig import com.klaviyo.core.Registry import com.klaviyo.core.config.Config import com.klaviyo.fixtures.BaseTest import io.mockk.every import io.mockk.mockk -import io.mockk.unmockkObject import io.mockk.verify import org.junit.After import org.junit.Before @@ -24,12 +25,12 @@ internal class KlaviyoPreInitializeTest : BaseTest() { every { applicationContext(any()) } returns this every { build() } answers { configBuilt = true - configMock + mockConfig } // Mock real world behavior where accessing data store prior to initializing throws MissingConfig - // While also allowing dataStoreSpy to work post-initialization - every { dataStoreSpy.fetch(any()) } answers { + // While also allowing spyDataStore to work post-initialization + every { spyDataStore.fetch(any()) } answers { if (!configBuilt) { throw MissingConfig() } else { @@ -41,16 +42,18 @@ internal class KlaviyoPreInitializeTest : BaseTest() { private val mockApiClient: ApiClient = mockk().apply { every { startService() } returns Unit every { onApiRequest(any(), any()) } returns Unit + every { offApiRequest(any()) } returns Unit every { enqueueProfile(any()) } returns Unit every { enqueueEvent(any(), any()) } returns Unit every { enqueuePushToken(any(), any()) } returns Unit + every { enqueueUnregisterPushToken(any(), any(), any()) } returns Unit } @Before override fun setup() { super.setup() every { Registry.configBuilder } returns mockBuilder - every { contextMock.applicationContext } returns mockk().apply { + every { mockContext.applicationContext } returns mockk().apply { every { unregisterActivityLifecycleCallbacks(any()) } returns Unit every { registerActivityLifecycleCallbacks(any()) } returns Unit } @@ -59,14 +62,16 @@ internal class KlaviyoPreInitializeTest : BaseTest() { @After override fun cleanup() { - unmockkObject(UserInfo) Registry.unregister() + Registry.get().reset() + Registry.unregister() + Registry.unregister() Registry.unregister() super.cleanup() } private inline fun assertCaught() where T : Throwable { - verify { logSpy.error(any(), any()) } + verify { spyLog.error(any(), any()) } } @Test @@ -81,12 +86,12 @@ internal class KlaviyoPreInitializeTest : BaseTest() { Klaviyo.initialize( apiKey = API_KEY, - applicationContext = contextMock + applicationContext = mockContext ) Klaviyo.initialize( apiKey = "different-$API_KEY", - applicationContext = contextMock + applicationContext = mockContext ) verify(inverse = true) { diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoTest.kt index 5c08e849c..33648197c 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoTest.kt @@ -1,7 +1,6 @@ package com.klaviyo.analytics import android.app.Application -import android.app.Application.ActivityLifecycleCallbacks import android.content.Intent import android.os.Bundle import com.klaviyo.analytics.model.Event @@ -10,19 +9,25 @@ import com.klaviyo.analytics.model.EventMetric import com.klaviyo.analytics.model.Profile import com.klaviyo.analytics.model.ProfileKey import com.klaviyo.analytics.networking.ApiClient +import com.klaviyo.analytics.state.KlaviyoState +import com.klaviyo.analytics.state.State +import com.klaviyo.analytics.state.StateSideEffects import com.klaviyo.core.Registry import com.klaviyo.core.config.Config import com.klaviyo.fixtures.BaseTest -import com.klaviyo.fixtures.StaticClock import io.mockk.every import io.mockk.mockk +import io.mockk.mockkConstructor import io.mockk.slot +import io.mockk.unmockkConstructor import io.mockk.verify import io.mockk.verifyAll import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -51,54 +56,59 @@ internal class KlaviyoTest : BaseTest() { // Mocking an intent to return the stub push payload... val intent = mockk() val bundle = mockk() - var gettingKey = "" every { intent.extras } returns bundle every { bundle.keySet() } returns payload.keys - every { - intent.getStringExtra( - match { s -> - gettingKey = s // there must be a better way to do this... - true - } - ) - } answers { payload[gettingKey] } - every { - bundle.getString( - match { s -> - gettingKey = s // there must be a better way to do this... - true - }, - String() - ) - } answers { payload[gettingKey] } + every { intent.getStringExtra(any()) } answers { call -> payload[call.invocation.args[0]] } + every { bundle.getString(any(), String()) } answers { call -> payload[call.invocation.args[0]] } return intent } } private val capturedProfile = slot() - private val staticClock = StaticClock(TIME, ISO_TIME) - private val debounceTime = 5 - private val apiClientMock: ApiClient = mockk() + private val mockApiClient: ApiClient = mockk().apply { + every { startService() } returns Unit + every { onApiRequest(any(), any()) } returns Unit + every { offApiRequest(any()) } returns Unit + every { enqueueProfile(capture(capturedProfile)) } returns Unit + every { enqueueEvent(any(), any()) } returns Unit + every { enqueuePushToken(any(), any()) } returns Unit + } + + private val mockBuilder = mockk().apply { + every { apiKey(any()) } returns this + every { applicationContext(any()) } returns this + every { build() } returns mockConfig + } + + private val mockApplication = mockk().apply { + every { mockContext.applicationContext } returns this + every { unregisterActivityLifecycleCallbacks(any()) } returns Unit + every { registerActivityLifecycleCallbacks(any()) } returns Unit + } @Before override fun setup() { super.setup() - Registry.register { apiClientMock } - every { Registry.clock } returns staticClock - every { apiClientMock.startService() } returns Unit - every { apiClientMock.onApiRequest(any(), any()) } returns Unit - every { apiClientMock.enqueueProfile(capture(capturedProfile)) } returns Unit - every { apiClientMock.enqueueEvent(any(), any()) } returns Unit - every { apiClientMock.enqueuePushToken(any(), any()) } returns Unit - every { configMock.debounceInterval } returns debounceTime + every { Registry.configBuilder } returns mockBuilder + Registry.register(mockApiClient) DevicePropertiesTest.mockDeviceProperties() - UserInfo.reset() + mockkConstructor(StateSideEffects::class) + every { anyConstructed().detach() } returns Unit + Klaviyo.initialize( + apiKey = API_KEY, + applicationContext = mockContext + ) } @After override fun cleanup() { - UserInfo.reset() + unmockkConstructor(StateSideEffects::class) + Registry.get().reset() + Registry.unregister() + Registry.unregister() + Registry.unregister() + Registry.unregister() super.cleanup() Registry.unregister() DevicePropertiesTest.unmockDeviceProperties() @@ -106,48 +116,31 @@ internal class KlaviyoTest : BaseTest() { @Test fun `Registered mock api`() { - assertEquals(apiClientMock, Registry.get()) + assertEquals(mockApiClient, Registry.get()) + verify { mockApiClient.startService() } } @Test - fun `initialize properly creates new config service and attaches lifecycle listeners`() { - val builderMock = mockk() - every { Registry.configBuilder } returns builderMock - every { builderMock.apiKey(any()) } returns builderMock - every { builderMock.applicationContext(any()) } returns builderMock - every { builderMock.build() } returns configMock - - val mockApplication = mockk() - every { contextMock.applicationContext } returns mockApplication.also { - every { it.unregisterActivityLifecycleCallbacks(any()) } returns Unit - every { it.registerActivityLifecycleCallbacks(any()) } returns Unit - } - - Klaviyo.initialize( - apiKey = API_KEY, - applicationContext = contextMock - ) + fun `Registered state and side effects`() { + assertTrue(Registry.get() is KlaviyoState) + assertNotNull(Registry.getOrNull()) + } + @Test + fun `Initialize properly creates new config service and attaches lifecycle listeners`() { val expectedListener = Registry.lifecycleCallbacks verifyAll { - builderMock.apiKey(API_KEY) - builderMock.applicationContext(contextMock) - builderMock.build() + mockBuilder.apiKey(API_KEY) + mockBuilder.applicationContext(mockContext) + mockBuilder.build() mockApplication.unregisterActivityLifecycleCallbacks(match { it == expectedListener }) mockApplication.registerActivityLifecycleCallbacks(match { it == expectedListener }) } } - @Test - fun `Klaviyo does not make core lifecycle callbacks service publicly available`() { - val mockLifecycleCallbacks = mockk() - every { Registry.lifecycleCallbacks } returns mockLifecycleCallbacks - assertNotEquals(mockLifecycleCallbacks, Klaviyo.lifecycleCallbacks) - } - private fun verifyProfileDebounced() { staticClock.execute(debounceTime.toLong()) - verify(exactly = 1) { apiClientMock.enqueueProfile(any()) } + verify(exactly = 1) { mockApiClient.enqueueProfile(any()) } } @Test @@ -156,7 +149,7 @@ internal class KlaviyoTest : BaseTest() { .setEmail(EMAIL) .setPhoneNumber(PHONE) - verify(exactly = 0) { apiClientMock.enqueueProfile(any()) } + verify(exactly = 0) { mockApiClient.enqueueProfile(any()) } verifyProfileDebounced() } @@ -173,7 +166,7 @@ internal class KlaviyoTest : BaseTest() { .setProfileAttribute(ProfileKey.LAST_NAME, stubLastName) .setProfileAttribute(stubMiddleNameKey, stubMiddleName) - verify(exactly = 0) { apiClientMock.enqueueProfile(any()) } + verify(exactly = 0) { mockApiClient.enqueueProfile(any()) } verifyProfileDebounced() assert(capturedProfile.isCaptured) val profile = capturedProfile.captured @@ -236,10 +229,10 @@ internal class KlaviyoTest : BaseTest() { Klaviyo.setPhoneNumber("") verifyProfileDebounced() // Should not have enqueued a new request - verify(exactly = 3) { logSpy.warning(any(), null) } - assertEquals(EXTERNAL_ID, UserInfo.externalId) - assertEquals(EMAIL, UserInfo.email) - assertEquals(PHONE, UserInfo.phoneNumber) + verify(exactly = 3) { spyLog.warning(any(), null) } + assertEquals(EXTERNAL_ID, Registry.get().externalId) + assertEquals(EMAIL, Registry.get().email) + assertEquals(PHONE, Registry.get().phoneNumber) } @Test @@ -270,38 +263,38 @@ internal class KlaviyoTest : BaseTest() { fun `setProfile is debounced`() { Klaviyo.setProfile(Profile().setEmail(EMAIL)) - verify(exactly = 0) { apiClientMock.enqueueProfile(any()) } + verify(exactly = 0) { mockApiClient.enqueueProfile(any()) } verifyProfileDebounced() } @Test fun `setProfile merges into an anonymous profile`() { - val anonId = UserInfo.anonymousId + val anonId = Registry.get().anonymousId Klaviyo.setProfile(Profile().setEmail(EMAIL)) - assertEquals(EMAIL, UserInfo.email) - assertEquals(anonId, UserInfo.anonymousId) + assertEquals(EMAIL, Registry.get().email) + assertEquals(anonId, Registry.get().anonymousId) } @Test fun `setProfile resets current profile and passes new identifiers to UserInfo`() { - UserInfo.email = "other" - val anonId = UserInfo.anonymousId + Registry.get().email = "other" + val anonId = Registry.get().anonymousId val newProfile = Profile().setExternalId(EXTERNAL_ID) Klaviyo.setProfile(newProfile) - assertEquals(EXTERNAL_ID, UserInfo.externalId) - assertEquals("", UserInfo.email) - assertNotEquals(anonId, UserInfo.anonymousId) + assertEquals(EXTERNAL_ID, Registry.get().externalId) + assertNull(Registry.get().email) + assertNotEquals(anonId, Registry.get().anonymousId) } @Test fun `Sets user external ID into info`() { Klaviyo.setExternalId(EXTERNAL_ID) - assertEquals(EXTERNAL_ID, UserInfo.externalId) + assertEquals(EXTERNAL_ID, Registry.get().externalId) verifyProfileDebounced() } @@ -309,7 +302,7 @@ internal class KlaviyoTest : BaseTest() { fun `Sets user email into info`() { Klaviyo.setEmail(EMAIL) - assertEquals(EMAIL, UserInfo.email) + assertEquals(EMAIL, Registry.get().email) verifyProfileDebounced() } @@ -317,7 +310,7 @@ internal class KlaviyoTest : BaseTest() { fun `Sets user phone into info`() { Klaviyo.setPhoneNumber(PHONE) - assertEquals(PHONE, UserInfo.phoneNumber) + assertEquals(PHONE, Registry.get().phoneNumber) verifyProfileDebounced() } @@ -325,39 +318,69 @@ internal class KlaviyoTest : BaseTest() { fun `Sets an arbitrary user property`() { val stubName = "Gonzo" Klaviyo.setProfileAttribute(ProfileKey.FIRST_NAME, stubName) + assertEquals( + stubName, + Registry.get().getAsProfile(withAttributes = true)[ProfileKey.FIRST_NAME] + ) + } + + @Test + fun `Enqueues API call for an arbitrary user property`() { + val stubName = "Gonzo" + Klaviyo.setProfileAttribute(ProfileKey.FIRST_NAME, stubName) verifyProfileDebounced() assert(capturedProfile.isCaptured) assertEquals(stubName, capturedProfile.captured[ProfileKey.FIRST_NAME]) } + @Test + fun `Sets a serializable user property`() { + val bestNumber = 4 + Klaviyo.setProfileAttribute(ProfileKey.FIRST_NAME, bestNumber) + + verifyProfileDebounced() + assert(capturedProfile.isCaptured) + assertEquals(bestNumber, capturedProfile.captured[ProfileKey.FIRST_NAME]) + } + + @Test + fun `Serialiazable not in identifiers still gets debounced`() { + val bestString = "" + val key = ProfileKey.CUSTOM("danKey") + Klaviyo.setProfileAttribute(key, bestString) + + verifyProfileDebounced() + assert(capturedProfile.isCaptured) + assertEquals(bestString, capturedProfile.captured[key]) + } + @Test fun `Resets user info`() { - val anonId = UserInfo.anonymousId - UserInfo.email = EMAIL - UserInfo.phoneNumber = PHONE - UserInfo.externalId = EXTERNAL_ID + val anonId = Registry.get().anonymousId + Registry.get().email = EMAIL + Registry.get().phoneNumber = PHONE + Registry.get().externalId = EXTERNAL_ID Klaviyo.resetProfile() - assertNotEquals(anonId, UserInfo.anonymousId) - assertEquals("", UserInfo.email) - assertEquals("", UserInfo.phoneNumber) - assertEquals("", UserInfo.externalId) + assertNotEquals(anonId, Registry.get().anonymousId) + assertNull(Registry.get().email) + assertNull(Registry.get().phoneNumber) + assertNull(Registry.get().externalId) - // Shouldn't make an API request by default - verify(inverse = true) { apiClientMock.enqueueProfile(any()) } + // Resetting profile flushes the queue immediately + verify(exactly = 1) { mockApiClient.enqueueProfile(any()) } } @Test fun `Reset removes push token from store`() { - UserInfo.email = EMAIL - dataStoreSpy.store("push_token", PUSH_TOKEN) + Registry.get().email = EMAIL + spyDataStore.store("push_token", PUSH_TOKEN) Klaviyo.resetProfile() - assertEquals("", UserInfo.email) - assertEquals(null, dataStoreSpy.fetch("push_token")) + assertNull(null, Registry.get().email) } @Test @@ -366,9 +389,9 @@ internal class KlaviyoTest : BaseTest() { assertNull(Klaviyo.getPhoneNumber()) assertNull(Klaviyo.getExternalId()) - UserInfo.email = EMAIL - UserInfo.phoneNumber = PHONE - UserInfo.externalId = EXTERNAL_ID + Registry.get().email = EMAIL + Registry.get().phoneNumber = PHONE + Registry.get().externalId = EXTERNAL_ID assertEquals(EMAIL, Klaviyo.getEmail()) assertEquals(PHONE, Klaviyo.getPhoneNumber()) @@ -378,45 +401,93 @@ internal class KlaviyoTest : BaseTest() { @Test fun `Stores push token and Enqueues a push token API call`() { Klaviyo.setPushToken(PUSH_TOKEN) - assertEquals(PUSH_TOKEN, dataStoreSpy.fetch("push_token")) + assertEquals(PUSH_TOKEN, spyDataStore.fetch("push_token")) verify(exactly = 1) { - apiClientMock.enqueuePushToken(PUSH_TOKEN, any()) + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } } @Test fun `Push token request is ignored if state has not changed`() { Klaviyo.setPushToken(PUSH_TOKEN) - assertEquals(PUSH_TOKEN, dataStoreSpy.fetch("push_token")) + assertEquals(PUSH_TOKEN, spyDataStore.fetch("push_token")) verify(exactly = 1) { - apiClientMock.enqueuePushToken(PUSH_TOKEN, any()) + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } Klaviyo.setPushToken(PUSH_TOKEN) verify(exactly = 1) { - apiClientMock.enqueuePushToken(PUSH_TOKEN, any()) + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } } @Test fun `Push token request is repeated if state has changed`() { - every { DeviceProperties.backgroundData } returns true + every { DeviceProperties.backgroundDataEnabled } returns true Klaviyo.setPushToken(PUSH_TOKEN) - assertEquals(PUSH_TOKEN, dataStoreSpy.fetch("push_token")) + assertEquals(PUSH_TOKEN, spyDataStore.fetch("push_token")) verify(exactly = 1) { - apiClientMock.enqueuePushToken(PUSH_TOKEN, any()) + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } - every { DeviceProperties.backgroundData } returns false + every { DeviceProperties.backgroundDataEnabled } returns false Klaviyo.setPushToken(PUSH_TOKEN) verify(exactly = 2) { - apiClientMock.enqueuePushToken(PUSH_TOKEN, any()) + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) + } + } + + @Test + fun `Push token request is made if profile identifiers change and token is set`() { + Klaviyo.setPushToken(PUSH_TOKEN) + assertEquals(PUSH_TOKEN, spyDataStore.fetch("push_token")) + + verify(exactly = 1) { + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) + } + + Klaviyo.setEmail(EMAIL) + .setPhoneNumber(PHONE) + .setExternalId(EXTERNAL_ID) + + staticClock.execute(debounceTime.toLong()) + verify(exactly = 2) { mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } + } + + @Test + fun `Push token request is made if profile changes and token is set`() { + Klaviyo.setPushToken(PUSH_TOKEN) + assertEquals(PUSH_TOKEN, spyDataStore.fetch("push_token")) + + verify(exactly = 1) { + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) + } + + Klaviyo.setProfile(Profile().setEmail(EMAIL)) + + staticClock.execute(debounceTime.toLong()) + verify(exactly = 2) { mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } + } + + @Test + fun `Push token request is made for profile attributes when token is set`() { + Klaviyo.setPushToken(PUSH_TOKEN) + assertEquals(PUSH_TOKEN, spyDataStore.fetch("push_token")) + + verify(exactly = 1) { + mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } + + Klaviyo.setProfileAttribute(ProfileKey.FIRST_NAME, "Larry") + Klaviyo.setProfileAttribute(ProfileKey.LAST_NAME, "David") + + staticClock.execute(debounceTime.toLong()) + verify(exactly = 2) { mockApiClient.enqueuePushToken(PUSH_TOKEN, any()) } } @Test @@ -428,7 +499,7 @@ internal class KlaviyoTest : BaseTest() { @Test fun `Fetches push token from persistent store`() { - dataStoreSpy.store("push_token", PUSH_TOKEN) + spyDataStore.store("push_token", PUSH_TOKEN) assertEquals(Klaviyo.getPushToken(), PUSH_TOKEN) } @@ -437,7 +508,7 @@ internal class KlaviyoTest : BaseTest() { // Handle push intent Klaviyo.handlePush(mockIntent(stubIntentExtras)) - verify { apiClientMock.enqueueEvent(any(), any()) } + verify { mockApiClient.enqueueEvent(any(), any()) } } @Test @@ -446,7 +517,7 @@ internal class KlaviyoTest : BaseTest() { Klaviyo.handlePush(mockIntent(mapOf("com.other.package.message" to "3rd party push"))) Klaviyo.handlePush(null) - verify(inverse = true) { apiClientMock.enqueueEvent(any(), any()) } + verify(inverse = true) { mockApiClient.enqueueEvent(any(), any()) } } @Test @@ -455,7 +526,7 @@ internal class KlaviyoTest : BaseTest() { Klaviyo.createEvent(stubEvent) verify(exactly = 1) { - apiClientMock.enqueueEvent(stubEvent, any()) + mockApiClient.enqueueEvent(stubEvent, any()) } } @@ -464,7 +535,22 @@ internal class KlaviyoTest : BaseTest() { Klaviyo.createEvent(EventMetric.VIEWED_PRODUCT) verify(exactly = 1) { - apiClientMock.enqueueEvent(match { it.metric == EventMetric.VIEWED_PRODUCT }, any()) + mockApiClient.enqueueEvent(match { it.metric == EventMetric.VIEWED_PRODUCT }, any()) } } + + @Test + fun `State side effect attachments are idempotent`() { + verify(exactly = 0) { anyConstructed().detach() } + Klaviyo.initialize( + apiKey = API_KEY, + applicationContext = mockContext + ) + verify(exactly = 1) { anyConstructed().detach() } + Klaviyo.initialize( + apiKey = API_KEY, + applicationContext = mockContext + ) + verify(exactly = 2) { anyConstructed().detach() } + } } diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoUninitializedTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoUninitializedTest.kt index 73b2886fd..269afb259 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoUninitializedTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoUninitializedTest.kt @@ -19,12 +19,12 @@ import org.junit.Test internal class KlaviyoUninitializedTest { - private val logger = spyk(LogFixture()) + private val spyLog = spyk(LogFixture()) @Before fun setup() { mockkObject(Registry) - every { Registry.log } returns logger + every { Registry.log } returns spyLog } @After @@ -33,7 +33,7 @@ internal class KlaviyoUninitializedTest { } private inline fun assertCaught() where T : Throwable { - verify { logger.error(any(), any()) } + verify { spyLog.error(any(), any()) } } @Test @@ -86,7 +86,7 @@ internal class KlaviyoUninitializedTest { @Test fun `Push token getter is protected`() { - assertNull(Klaviyo.getPushToken()) + Klaviyo.getPushToken() assertCaught() } diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/UserInfoTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/UserInfoTest.kt deleted file mode 100644 index e66bd3736..000000000 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/UserInfoTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.klaviyo.analytics - -import com.klaviyo.analytics.model.Profile -import com.klaviyo.analytics.model.ProfileKey -import com.klaviyo.fixtures.BaseTest -import io.mockk.verify -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test - -internal class UserInfoTest : BaseTest() { - - @Before - override fun setup() { - super.setup() - UserInfo.reset() - } - - @Test - fun `UserInfo is convertible to Profile`() { - UserInfo.externalId = EXTERNAL_ID - UserInfo.email = EMAIL - UserInfo.phoneNumber = PHONE - assertProfileIdentifiers(UserInfo.getAsProfile()) - assertUserInfoIdentifiers() - } - - @Test - fun `create and store a new UUID if one does not exists in data store`() { - val anonId = UserInfo.anonymousId - val fetched = dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name) - assertEquals(anonId, fetched) - } - - @Test - fun `do not create new UUID if one exists in data store`() { - dataStoreSpy.store(ProfileKey.ANONYMOUS_ID.name, ANON_ID) - assertEquals(ANON_ID, UserInfo.anonymousId) - } - - @Test - fun `only read properties from data store once`() { - dataStoreSpy.store(ProfileKey.ANONYMOUS_ID.name, ANON_ID) - dataStoreSpy.store(ProfileKey.EMAIL.name, EMAIL) - dataStoreSpy.store(ProfileKey.EXTERNAL_ID.name, EXTERNAL_ID) - dataStoreSpy.store(ProfileKey.PHONE_NUMBER.name, PHONE) - - UserInfo.anonymousId - assertEquals(UserInfo.anonymousId, ANON_ID) - verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name) } - - UserInfo.email - assertEquals(UserInfo.email, EMAIL) - verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.EMAIL.name) } - - UserInfo.externalId - assertEquals(UserInfo.externalId, EXTERNAL_ID) - verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.EXTERNAL_ID.name) } - - UserInfo.phoneNumber - assertEquals(UserInfo.phoneNumber, PHONE) - verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.PHONE_NUMBER.name) } - } - - @Test - fun `Anonymous ID lifecycle`() { - // Should be null after a reset... - val initialAnonId = dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name) - assertNull(initialAnonId) - - // Start tracking a new anon ID and it should be persisted - val firstAnonId = UserInfo.anonymousId - assertEquals(firstAnonId, dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name)) - - // Reset again should nullify in data store - UserInfo.reset() - assertNull(dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name)) - - // Start tracking again should generate another new anon ID - val newAnonId = UserInfo.anonymousId - assertNotEquals(firstAnonId, newAnonId) - assertEquals(newAnonId, dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name)) - } - - private fun assertProfileIdentifiers(profile: Profile) { - assert(profile.externalId == EXTERNAL_ID) - assert(profile.email == EMAIL) - assert(profile.phoneNumber == PHONE) - assert(profile.anonymousId == UserInfo.anonymousId) - assert(profile.toMap().count() == 4) // shouldn't contain any extras - } - - private fun assertUserInfoIdentifiers() { - assert(UserInfo.externalId == EXTERNAL_ID) - assert(UserInfo.email == EMAIL) - assert(UserInfo.phoneNumber == PHONE) - } -} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/model/KeywordsTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/model/KeywordsTest.kt index bff90e542..6f8836587 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/model/KeywordsTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/model/KeywordsTest.kt @@ -1,5 +1,6 @@ package com.klaviyo.analytics.model +import com.klaviyo.fixtures.BaseTest.Companion.EMAIL import org.junit.Assert.assertEquals import org.junit.Test @@ -33,15 +34,17 @@ class KeywordsTest { assert(custom == ProfileKey.CUSTOM(expectedCustomKey)) assert(custom != ProfileKey.CUSTOM(expectedCustomKey + "1")) - assertEquals("\$anonymous", ProfileKey.ANONYMOUS_ID.specialKey()) - assertEquals("\$id", ProfileKey.EXTERNAL_ID.specialKey()) - assertEquals("\$email", ProfileKey.EMAIL.specialKey()) - assertEquals("\$phone_number", ProfileKey.PHONE_NUMBER.specialKey()) - assertEquals("custom", ProfileKey.CUSTOM("custom").specialKey()) - assertEquals(ProfileKey.EMAIL, ProfileKey.CUSTOM("email")) } + @Test + fun `A custom key is interchangeable with named key if name string matches`() { + assertEquals( + EMAIL, + Profile(mapOf(ProfileKey.CUSTOM("email") to EMAIL)).email + ) + } + @Test fun `Event type keys`() { assertEquals("\$opened_push", EventMetric.OPENED_PUSH.name) diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/model/ModelTests.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/model/ModelTests.kt index fc1905add..01e806624 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/model/ModelTests.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/model/ModelTests.kt @@ -34,6 +34,26 @@ internal class ModelTests : BaseTest() { ) } + @Test + fun `Attributes converts to a Profile object with identifiers stripped out`() { + val custKey = ProfileKey.CUSTOM("custom_key") + val profileAttributes = Profile( + externalId = EXTERNAL_ID, + email = EMAIL, + phoneNumber = PHONE, + properties = mapOf( + ProfileKey.FIRST_NAME to "kermit", + custKey to "test" + ) + ).attributes + + assertNull(profileAttributes.externalId) + assertNull(profileAttributes.email) + assertNull(profileAttributes.phoneNumber) + assertEquals("test", profileAttributes[custKey]) + assertEquals("kermit", profileAttributes[ProfileKey.FIRST_NAME]) + } + @Test fun `Get, set and unset`() { val event = Event("test") diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/KlaviyoApiClientTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/KlaviyoApiClientTest.kt index 8dac82333..f3c647c72 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/KlaviyoApiClientTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/KlaviyoApiClientTest.kt @@ -19,7 +19,6 @@ import com.klaviyo.core.lifecycle.ActivityObserver import com.klaviyo.core.networking.NetworkMonitor import com.klaviyo.core.networking.NetworkObserver import com.klaviyo.fixtures.BaseTest -import com.klaviyo.fixtures.StaticClock import io.mockk.every import io.mockk.mockk import io.mockk.mockkConstructor @@ -50,7 +49,6 @@ internal class KlaviyoApiClientTest : BaseTest() { private val flushIntervalOffline = 30_000L private val queueDepth = 10 private var postedJob: KlaviyoApiClient.NetworkRunnable? = null - private val staticClock = StaticClock(TIME, ISO_TIME) private companion object { private val slotOnActivityEvent = slot() @@ -68,19 +66,18 @@ internal class KlaviyoApiClientTest : BaseTest() { postedJob = null - every { Registry.clock } returns staticClock - every { configMock.networkFlushIntervals } returns longArrayOf( + every { mockConfig.networkFlushIntervals } returns longArrayOf( flushIntervalWifi, flushIntervalCell, flushIntervalOffline ) - every { configMock.networkFlushDepth } returns queueDepth - every { networkMonitorMock.isNetworkConnected() } returns false - every { networkMonitorMock.getNetworkType() } returns NetworkMonitor.NetworkType.Wifi - every { lifecycleMonitorMock.onActivityEvent(capture(slotOnActivityEvent)) } returns Unit - every { lifecycleMonitorMock.offActivityEvent(capture(slotOnActivityEvent)) } returns Unit - every { networkMonitorMock.onNetworkChange(capture(slotOnNetworkChange)) } returns Unit - every { networkMonitorMock.offNetworkChange(capture(slotOnNetworkChange)) } returns Unit + every { mockConfig.networkFlushDepth } returns queueDepth + every { mockNetworkMonitor.isNetworkConnected() } returns false + every { mockNetworkMonitor.getNetworkType() } returns NetworkMonitor.NetworkType.Wifi + every { mockLifecycleMonitor.onActivityEvent(capture(slotOnActivityEvent)) } returns Unit + every { mockLifecycleMonitor.offActivityEvent(capture(slotOnActivityEvent)) } returns Unit + every { mockNetworkMonitor.onNetworkChange(capture(slotOnNetworkChange)) } returns Unit + every { mockNetworkMonitor.offNetworkChange(capture(slotOnNetworkChange)) } returns Unit mockkObject(HandlerUtil) every { HandlerUtil.getHandler(any()) } returns mockHandler.apply { @@ -105,7 +102,7 @@ internal class KlaviyoApiClientTest : BaseTest() { @After override fun cleanup() { - dataStoreSpy.clear(KlaviyoApiClient.QUEUE_KEY) + spyDataStore.clear(KlaviyoApiClient.QUEUE_KEY) KlaviyoApiClient.restoreQueue() assertEquals(0, KlaviyoApiClient.getQueueSize()) super.cleanup() @@ -116,7 +113,8 @@ internal class KlaviyoApiClientTest : BaseTest() { private fun mockRequest( uuid: String = "uuid", - status: KlaviyoApiRequest.Status = KlaviyoApiRequest.Status.Complete + status: KlaviyoApiRequest.Status = KlaviyoApiRequest.Status.Complete, + codeOverride: Int? = null ): KlaviyoApiRequest = spyk(KlaviyoApiRequest("https://mock.com", RequestMethod.GET)).also { every { it.status } returns status @@ -148,7 +146,7 @@ internal class KlaviyoApiClientTest : BaseTest() { every { it.responseHeaders } returns emptyMap() every { it.responseBody } returns null every { it.responseCode } answers { - when (getState()) { + codeOverride ?: when (getState()) { KlaviyoApiRequest.Status.PendingRetry -> 429 KlaviyoApiRequest.Status.Complete -> 202 KlaviyoApiRequest.Status.Failed -> 500 @@ -222,7 +220,7 @@ internal class KlaviyoApiClientTest : BaseTest() { } assertEquals(1, KlaviyoApiClient.getQueueSize()) - verify(exactly = 1) { logSpy.verbose("Persisting queue") } + verify(exactly = 1) { spyLog.verbose("Persisting queue") } } @Test @@ -270,8 +268,8 @@ internal class KlaviyoApiClientTest : BaseTest() { assertEquals(1, KlaviyoApiClient.getQueueSize()) // Listeners should have been removed - verify { lifecycleMonitorMock.offActivityEvent(priorOnActivityEvent) } - verify { networkMonitorMock.offNetworkChange(priorOnNetworkChange) } + verify { mockLifecycleMonitor.offActivityEvent(priorOnActivityEvent) } + verify { mockNetworkMonitor.offNetworkChange(priorOnNetworkChange) } } @Test @@ -299,7 +297,7 @@ internal class KlaviyoApiClientTest : BaseTest() { KlaviyoApiClient.onApiRequest(true) { cbRequest = it } assertEquals(request, cbRequest) - verify { logSpy.verbose(match { it.contains("queue") }) } + verify { spyLog.verbose(match { it.contains("queue") }) } } @Test @@ -310,7 +308,7 @@ internal class KlaviyoApiClientTest : BaseTest() { val request = mockRequest(status = KlaviyoApiRequest.Status.Unsent) KlaviyoApiClient.enqueueRequest(request) assertEquals(request, cbRequest) - verify { logSpy.verbose(match { it.contains("queue") }) } + verify { spyLog.verbose(match { it.contains("queue") }) } } @Test @@ -335,7 +333,7 @@ internal class KlaviyoApiClientTest : BaseTest() { @Test fun `Invokes callback and logs when request sent`() { - every { configMock.networkFlushDepth } returns 1 + every { mockConfig.networkFlushDepth } returns 1 val request = mockRequest() KlaviyoApiClient.enqueueRequest(request) @@ -344,7 +342,7 @@ internal class KlaviyoApiClientTest : BaseTest() { postedJob!!.run() assertEquals(request, cbRequest) - verify { logSpy.verbose(match { it.contains("succeed") }) } + verify { spyLog.verbose(match { it.contains("succeed") }) } } @Test @@ -441,7 +439,7 @@ internal class KlaviyoApiClientTest : BaseTest() { KlaviyoApiClient.flushQueue() assertEquals(0, KlaviyoApiClient.getQueueSize()) - assertNull(dataStoreSpy.fetch(fail)) + assertNull(spyDataStore.fetch(fail)) } @Test @@ -453,7 +451,7 @@ internal class KlaviyoApiClientTest : BaseTest() { KlaviyoApiClient.flushQueue() assertEquals(1, KlaviyoApiClient.getQueueSize()) - assertNotNull(dataStoreSpy.fetch(uuid)) + assertNotNull(spyDataStore.fetch(uuid)) } @Test @@ -504,7 +502,61 @@ internal class KlaviyoApiClientTest : BaseTest() { // Upon final failure, request 1 should have been dropped from the queue assertEquals(1, KlaviyoApiClient.getQueueSize()) - assertNull(dataStoreSpy.fetch(request1.uuid)) + assertNull(spyDataStore.fetch(request1.uuid)) + + // Second request should have been attempted after the final failure of request 1 + verify(exactly = 1) { request2.send(any()) } + } + + @Test + fun `503 results in a retry using exponential backoff`() { + // same as above test but with a 503 instead of a 429 + // First unsent request, which we will retry till max attempts (calculated by exponential) + // note that we are not sending the retry-after header on a 503 + val request1 = mockRequest("uuid-retry", KlaviyoApiRequest.Status.Unsent, 503) + + every { request1.state } answers { + when (request1.attempts) { + 0 -> KlaviyoApiRequest.Status.Unsent.name + 20 -> KlaviyoApiRequest.Status.Failed.name + else -> KlaviyoApiRequest.Status.PendingRetry.name + } + } + + // Second unset request in queue to ensure which shouldn't sent until first has failed + val request2 = mockRequest("uuid-unsent", KlaviyoApiRequest.Status.Unsent) + + // Enqueue 2 requests + KlaviyoApiClient.enqueueRequest(request1, request2) + + // Enqueueing should invoke handler.post and initialize our postedJob property + assertNotNull(postedJob) + + // But the clock has not advanced, so no requests should have been sent yet + assertEquals(0, request1.attempts) + + while (request1.state != KlaviyoApiRequest.Status.Failed.name) { + val startAttempts = request1.attempts + + // Advance the time with our expected backoff interval + staticClock.time += 50_000L + + // Run after advancing the clock (this mimics how handler.postDelay would run jobs) + postedJob!!.run() + + // It should have attempted one send if the correct time elapsed + assertEquals(startAttempts + 1, request1.attempts) + + // Check for repeat request but not above max + assert(request1.attempts in 1 until 21) + } + + // First request should have been retried exactly 20 times + assertEquals(20, request1.attempts) + + // Upon final failure, request 1 should have been dropped from the queue + assertEquals(1, KlaviyoApiClient.getQueueSize()) + assertNull(spyDataStore.fetch(request1.uuid)) // Second request should have been attempted after the final failure of request 1 verify(exactly = 1) { request2.send(any()) } @@ -569,7 +621,7 @@ internal class KlaviyoApiClientTest : BaseTest() { // Upon final failure, request 1 should have been dropped from the queue assertEquals(1, KlaviyoApiClient.getQueueSize()) - assertNull(dataStoreSpy.fetch(request1.uuid)) + assertNull(spyDataStore.fetch(request1.uuid)) // Second request should have been attempted after the final failure of request 1 verify(exactly = 1) { request2.send(any()) } @@ -577,36 +629,36 @@ internal class KlaviyoApiClientTest : BaseTest() { @Test fun `Network requests are persisted to disk`() { - dataStoreSpy.clear("mock_uuid1") - dataStoreSpy.clear("mock_uuid2") + spyDataStore.clear("mock_uuid1") + spyDataStore.clear("mock_uuid2") KlaviyoApiClient.enqueueRequest( mockRequest("mock_uuid1"), mockRequest("mock_uuid2") ) - assertNotEquals(null, dataStoreSpy.fetch("mock_uuid1")) - assertNotEquals(null, dataStoreSpy.fetch("mock_uuid2")) + assertNotEquals(null, spyDataStore.fetch("mock_uuid1")) + assertNotEquals(null, spyDataStore.fetch("mock_uuid2")) assertEquals( "[\"mock_uuid1\",\"mock_uuid2\"]", - dataStoreSpy.fetch(KlaviyoApiClient.QUEUE_KEY) + spyDataStore.fetch(KlaviyoApiClient.QUEUE_KEY) ) } @Test fun `Flushing queue empties persistent store`() { - dataStoreSpy.store("something_else", "test") - dataStoreSpy.clear(KlaviyoApiClient.QUEUE_KEY) + spyDataStore.store("something_else", "test") + spyDataStore.clear(KlaviyoApiClient.QUEUE_KEY) KlaviyoApiClient.enqueueRequest(mockRequest("mock_uuid")) - assertNotEquals(null, dataStoreSpy.fetch("mock_uuid")) - assertEquals("[\"mock_uuid\"]", dataStoreSpy.fetch(KlaviyoApiClient.QUEUE_KEY)) + assertNotEquals(null, spyDataStore.fetch("mock_uuid")) + assertEquals("[\"mock_uuid\"]", spyDataStore.fetch(KlaviyoApiClient.QUEUE_KEY)) KlaviyoApiClient.flushQueue() - assertEquals(null, dataStoreSpy.fetch("mock_uuid")) - assertEquals("[]", dataStoreSpy.fetch(KlaviyoApiClient.QUEUE_KEY)) - assertEquals("test", dataStoreSpy.fetch("something_else")) + assertEquals(null, spyDataStore.fetch("mock_uuid")) + assertEquals("[]", spyDataStore.fetch(KlaviyoApiClient.QUEUE_KEY)) + assertEquals("test", spyDataStore.fetch("something_else")) } @Test @@ -618,12 +670,12 @@ internal class KlaviyoApiClientTest : BaseTest() { } val expectedQueue = "[\"mock_uuid1\",\"mock_uuid2\"]" - dataStoreSpy.store(KlaviyoApiClient.QUEUE_KEY, expectedQueue) - dataStoreSpy.store("mock_uuid1", mockRequest("mock_uuid1").toString()) - dataStoreSpy.store("mock_uuid2", mockRequest("mock_uuid2").toString()) + spyDataStore.store(KlaviyoApiClient.QUEUE_KEY, expectedQueue) + spyDataStore.store("mock_uuid1", mockRequest("mock_uuid1").toString()) + spyDataStore.store("mock_uuid2", mockRequest("mock_uuid2").toString()) KlaviyoApiClient.restoreQueue() - val actualQueue = dataStoreSpy.fetch(KlaviyoApiClient.QUEUE_KEY) + val actualQueue = spyDataStore.fetch(KlaviyoApiClient.QUEUE_KEY) assertEquals(2, KlaviyoApiClient.getQueueSize()) assertEquals(expectedQueue, actualQueue) // Expect same order in the queue @@ -631,10 +683,10 @@ internal class KlaviyoApiClientTest : BaseTest() { @Test fun `Handles bad JSON queue gracefully`() { - dataStoreSpy.store(KlaviyoApiClient.QUEUE_KEY, "{}") // Bad JSON, isn't an array as expected + spyDataStore.store(KlaviyoApiClient.QUEUE_KEY, "{}") // Bad JSON, isn't an array as expected KlaviyoApiClient.restoreQueue() - val actualQueue = dataStoreSpy.fetch(KlaviyoApiClient.QUEUE_KEY) + val actualQueue = spyDataStore.fetch(KlaviyoApiClient.QUEUE_KEY) assertEquals(0, KlaviyoApiClient.getQueueSize()) assertEquals("[]", actualQueue) // Expect the persisted queue to be emptied @@ -649,15 +701,15 @@ internal class KlaviyoApiClientTest : BaseTest() { } val jsonArray = "[\"mock_uuid1\",\"mock_uuid2\",\"mock_uuid3\"]" - dataStoreSpy.store(KlaviyoApiClient.QUEUE_KEY, jsonArray) - dataStoreSpy.store("mock_uuid1", "{/}") // bad JSON! - dataStoreSpy.store("mock_uuid2", mockRequest("mock_uuid2").toString()) + spyDataStore.store(KlaviyoApiClient.QUEUE_KEY, jsonArray) + spyDataStore.store("mock_uuid1", "{/}") // bad JSON! + spyDataStore.store("mock_uuid2", mockRequest("mock_uuid2").toString()) KlaviyoApiClient.restoreQueue() - val actualQueue = dataStoreSpy.fetch(KlaviyoApiClient.QUEUE_KEY) + val actualQueue = spyDataStore.fetch(KlaviyoApiClient.QUEUE_KEY) assertEquals(1, KlaviyoApiClient.getQueueSize()) assertEquals("[\"mock_uuid2\"]", actualQueue) // Expect queue to reflect the dropped item - assertNull(dataStoreSpy.fetch("mock_uuid1")) // Expect the item to be cleared from store + assertNull(spyDataStore.fetch("mock_uuid1")) // Expect the item to be cleared from store } } diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/BaseApiRequestTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/BaseApiRequestTest.kt index f606a005e..ddae14b96 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/BaseApiRequestTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/BaseApiRequestTest.kt @@ -16,7 +16,7 @@ internal abstract class BaseApiRequestTest : BaseTest() where T : KlaviyoApiR open val expectedHeaders = mapOf( "Content-Type" to "application/json", "Accept" to "application/json", - "Revision" to "2023-07-15", + "Revision" to "1234-56-78", "User-Agent" to "Mock User Agent", "X-Klaviyo-Mobile" to "1", "X-Klaviyo-Attempt-Count" to "0/50" // Note: 0/50 is just the default, it increments to 1/50 before a real send! diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestTest.kt index 6bebeb50f..7e59210cb 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestTest.kt @@ -28,7 +28,7 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { override val expectedQuery: Map = emptyMap() - private val expectedFullUrl = "${configMock.baseUrl}/$expectedUrl" + private val expectedFullUrl = "${mockConfig.baseUrl}/$expectedUrl" private val bodySlot = slot() @@ -49,12 +49,29 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { return connectionSpy } + private fun withErrorConnectionMock(expectedUrl: URL, errorBodyString: String): HttpURLConnection { + val connectionSpy = spyk(expectedUrl.openConnection()) as HttpURLConnection + val inputStream = ByteArrayInputStream("success".toByteArray()) + val errorStream = ByteArrayInputStream(errorBodyString.toByteArray()) + + mockkObject(HttpUtil) + every { HttpUtil.openConnection(expectedUrl) } returns connectionSpy + every { HttpUtil.writeToConnection(capture(bodySlot), connectionSpy) } returns Unit + every { connectionSpy.connect() } returns Unit + every { connectionSpy.responseCode } returns 400 + every { connectionSpy.headerFields } returns emptyMap() + every { connectionSpy.inputStream } returns inputStream + every { connectionSpy.errorStream } returns errorStream + + return connectionSpy + } + @Before override fun setup() { super.setup() - every { networkMonitorMock.isNetworkConnected() } returns true - every { configMock.networkTimeout } returns 1 - every { configMock.networkFlushIntervals } returns longArrayOf(10_000L, 30_000L, 60_000L) + every { mockNetworkMonitor.isNetworkConnected() } returns true + every { mockConfig.networkTimeout } returns 1 + every { mockConfig.networkFlushIntervals } returns longArrayOf(10_000L, 30_000L, 60_000L) } @After @@ -136,7 +153,6 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { assertEquals(0, request.attempts) request.send() assertEquals(1, request.attempts) - assertEquals(request.headers["X-Klaviyo-Attempt-Count"], "1/50") verify { connectionMock.setRequestProperty("X-Klaviyo-Attempt-Count", "1/50") } } @@ -155,7 +171,7 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { @Test fun `Parses Retry-After header if present and adds jitter`() { val expectedHeaders = mapOf("Retry-After" to listOf("25")) - every { configMock.networkJitterRange } returns 1..1 + every { mockConfig.networkJitterRange } returns 1..1 withConnectionMock(URL(expectedFullUrl)).also { every { it.headerFields } returns expectedHeaders } @@ -168,8 +184,8 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { @Test fun `Falls back on network interval without jitter when Retry-After header is missing or invalid`() { // Wifi interval is 10s, force jitter to be 1s - every { networkMonitorMock.getNetworkType() } returns NetworkMonitor.NetworkType.Wifi - every { configMock.networkJitterRange } returns 1..1 + every { mockNetworkMonitor.getNetworkType() } returns NetworkMonitor.NetworkType.Wifi + every { mockConfig.networkJitterRange } returns 1..1 val request = makeTestRequest() assertEquals(10_000L, request.computeRetryInterval()) @@ -219,7 +235,7 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { @Test fun `Send returns unsent status when internet is unavailable`() { - every { networkMonitorMock.isNetworkConnected() } returns false + every { mockNetworkMonitor.isNetworkConnected() } returns false val request = makeTestRequest() @@ -256,7 +272,7 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { val request = makeTestRequest() - repeat(configMock.networkMaxAttempts - 1) { + repeat(mockConfig.networkMaxAttempts - 1) { // Should be retryable until max attempts hit assertEquals(KlaviyoApiRequest.Status.PendingRetry, request.send()) assertEquals(request.headers["X-Klaviyo-Attempt-Count"], "${it + 1}/50") @@ -399,4 +415,123 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest() { assertEquals(null, get.body) assertEquals(0, get.attempts) } + + @Test + fun `Malformed error response body`() { + withErrorConnectionMock( + URL(expectedFullUrl), + errorBodyString = """ + { + ckajns dlckjabsdlckjbsdcsc + kjdfns vkajn df + 8723986243 + all crabs are crustaceans + """.trimIndent() + ) + val expectedErrorBody = KlaviyoErrorResponse(listOf()) + val request = makeTestRequest() + request.send() + + assertEquals(request.errorBody, expectedErrorBody) + } + + @Test + fun `Empty error response body`() { + withErrorConnectionMock( + URL(expectedFullUrl), + errorBodyString = """ + { + } + """.trimIndent() + ) + val expectedErrorBody = KlaviyoErrorResponse(listOf()) + val request = makeTestRequest() + request.send() + + assertEquals(request.errorBody, expectedErrorBody) + } + + @Test + fun `Phone number format error body created`() { + withErrorConnectionMock( + URL(expectedFullUrl), + errorBodyString = """ + { + "errors": [ + { + "id": "67ed6dbf-1653-499b-a11d-30310aa01ff7", + "status": 400, + "code": "invalid", + "title": "Invalid input.", + "detail": "Invalid phone number format (Example of a valid format: +12345678901)", + "source": { + "pointer": "/data/attributes/phone_number" + }, + "links": {}, + "meta": {} + } + ] + } + """.trimIndent() + ) + val expectedErrorBody = KlaviyoErrorResponse( + listOf( + KlaviyoError( + id = "67ed6dbf-1653-499b-a11d-30310aa01ff7", + status = 400, + title = "Invalid input.", + detail = "Invalid phone number format (Example of a valid format: +12345678901)", + source = KlaviyoErrorSource( + pointer = "/data/attributes/phone_number" + ) + ) + ) + ) + val request = makeTestRequest() + request.send() + + assertEquals(request.errorBody, expectedErrorBody) + } + + @Test + fun `Email format error body created`() { + withErrorConnectionMock( + URL(expectedFullUrl), + errorBodyString = """ + { + "errors": [ + { + "id": "4f739784-390b-4df3-acd8-6eb07d60e6b4", + "status": 400, + "code": "invalid", + "title": "Invalid input.", + "detail": "Invalid email address", + "source": { + "pointer": "/data/attributes/email" + }, + "links": {}, + "meta": {} + } + ] + } + """.trimIndent() + ) + val expectedErrorBody = KlaviyoErrorResponse( + listOf( + KlaviyoError( + id = "4f739784-390b-4df3-acd8-6eb07d60e6b4", + status = 400, + title = "Invalid input.", + detail = "Invalid email address", + source = KlaviyoErrorSource( + pointer = "/data/attributes/email" + ) + ) + ) + ) + val request = makeTestRequest() + request.send() + + assertEquals(request.errorBody, expectedErrorBody) + } } diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoderTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoderTest.kt new file mode 100644 index 000000000..149e5adaa --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoErrorResponseDecoderTest.kt @@ -0,0 +1,142 @@ +package com.klaviyo.analytics.networking.requests + +import com.klaviyo.fixtures.BaseTest +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class KlaviyoErrorResponseDecoderTest : BaseTest() { + + @Test + fun `Builds error response`() { + val errorResponse = KlaviyoErrorResponse( + errors = listOf( + KlaviyoError( + id = "67ed6dbf-1653-499b-a11d-30310aa01ff7", + status = 400, + title = "Invalid input.", + detail = "Invalid phone number format (Example of a valid format: +12345678901)", + source = KlaviyoErrorSource( + pointer = "/data/attributes/phone_number" + ) + ) + ) + ) + val errorJson = """ + { + "errors": [ + { + "id": "67ed6dbf-1653-499b-a11d-30310aa01ff7", + "status": 400, + "code": "invalid", + "title": "Invalid input.", + "detail": "Invalid phone number format (Example of a valid format: +12345678901)", + "source": { + "pointer": "/data/attributes/phone_number" + }, + "links": {}, + "meta": {} + } + ] + } + """.trimIndent() + assertEquals(errorResponse, KlaviyoErrorResponseDecoder.fromJson(JSONObject(errorJson))) + } + + @Test + fun `Builds error response with some spooky nulls`() { + val errorResponse = KlaviyoErrorResponse( + errors = listOf( + KlaviyoError( + id = "67ed6dbf-1653-499b-a11d-30310aa01ff7", + status = -1, + title = "Invalid input.", + source = KlaviyoErrorSource() + ) + ) + ) + val errorJson = """ + { + "errors": [ + { + "id": "67ed6dbf-1653-499b-a11d-30310aa01ff7", + "status": -1, + "code": null, + "title": "Invalid input.", + "detail": null, + "source": { + "pointer": null + }, + "links": {}, + "meta": {} + } + ] + } + """.trimIndent() + assertEquals(errorResponse, KlaviyoErrorResponseDecoder.fromJson(JSONObject(errorJson))) + } + + @Test + fun `Build error response with an empty list of errors`() { + val errorResponse = KlaviyoErrorResponse( + errors = listOf() + ) + val errorJson = """ + { + "errors": [] + } + """.trimIndent() + assertEquals(errorResponse, KlaviyoErrorResponseDecoder.fromJson(JSONObject(errorJson))) + } + + @Test + fun `Build error response with an multiple errors`() { + val errorResponse = KlaviyoErrorResponse( + errors = listOf( + KlaviyoError( + id = "67ed6dbf-1653-499b-a11d-30310aa01ff7", + status = -1, + title = "Invalid input.", + source = KlaviyoErrorSource() + ), + KlaviyoError( + id = "123456", + status = 800, + title = "Invalid input.", + source = KlaviyoErrorSource() + ) + ) + ) + val errorJson = """ + { + "errors": [ + { + "id": "67ed6dbf-1653-499b-a11d-30310aa01ff7", + "status": -1, + "code": null, + "title": "Invalid input.", + "detail": null, + "source": { + "pointer": null + }, + "links": {}, + "meta": {} + }, + { + "id": "123456", + "status": 800, + "code": null, + "title": "Invalid input.", + "detail": null, + "source": { + "pointer": null + }, + "links": {}, + "meta": {} + } + ] + } + """.trimIndent() + assertEquals(errorResponse, KlaviyoErrorResponseDecoder.fromJson(JSONObject(errorJson))) + } +} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequestTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequestTest.kt index 2601f178c..ede9009ce 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequestTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequestTest.kt @@ -1,7 +1,9 @@ package com.klaviyo.analytics.networking.requests +import io.mockk.every import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Test internal class PushTokenApiRequestTest : BaseApiRequestTest() { @@ -32,6 +34,21 @@ internal class PushTokenApiRequestTest : BaseApiRequestTest assertEquals(aRequest, bRequest) } + @Test + fun `Requests are not equal if api key is different`() { + val aRequest = PushTokenApiRequest(PUSH_TOKEN, stubProfile) + every { mockConfig.apiKey } returns "NEW_API_KEY" + val bRequest = PushTokenApiRequest(PUSH_TOKEN, stubProfile) + assertNotEquals(aRequest, bRequest) + } + + @Test + fun `Requests are not equal if token is different`() { + val aRequest = PushTokenApiRequest(PUSH_TOKEN, stubProfile) + val bRequest = PushTokenApiRequest(PUSH_TOKEN.repeat(2), stubProfile) + assertNotEquals(aRequest, bRequest) + } + @Test fun `Builds body request`() { val expectJson = """ diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequestTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequestTest.kt new file mode 100644 index 000000000..2a32f80c6 --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/UnregisterPushTokenApiRequestTest.kt @@ -0,0 +1,79 @@ +package com.klaviyo.analytics.networking.requests + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +internal class UnregisterPushTokenApiRequestTest : BaseApiRequestTest() { + + override val expectedUrl = "client/push-token-unregister" + + override fun makeTestRequest(): UnregisterPushTokenApiRequest = + UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + + @Test + fun `Equality operator`() { + val aRequest = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + val bRequest = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + assertEquals(aRequest, bRequest) + + val bRequestDecoded = KlaviyoApiRequestDecoder.fromJson(bRequest.toJson()) + assertEquals(aRequest, bRequestDecoded) + assertEquals(aRequest.hashCode(), bRequestDecoded.hashCode()) + } + + @Test + fun `JSON interoperability`() = testJsonInterop(makeTestRequest()) + + @Test + fun `Requests are equal if the token and profile are equal`() { + val aRequest = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + val bRequest = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + assertEquals(aRequest, bRequest) + } + + @Test + fun `Requests are not equal if api key is different`() { + val aRequest = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + val bRequest = UnregisterPushTokenApiRequest(API_KEY.repeat(2), PUSH_TOKEN, stubProfile) + assertNotEquals(aRequest, bRequest) + } + + @Test + fun `Requests are not equal if token is different`() { + val aRequest = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + val bRequest = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN.repeat(2), stubProfile) + assertNotEquals(aRequest, bRequest) + } + + @Test + fun `Builds body request`() { + val expectJson = """ + { + "data": { + "type": "push-token-unregister", + "attributes": { + "token": "$PUSH_TOKEN", + "platform": "Android", + "vendor": "FCM", + "profile": { + "data": { + "type": "profile", + "attributes": { + "email": "$EMAIL", + "phone_number": "$PHONE", + "external_id": "$EXTERNAL_ID", + "anonymous_id": "$ANON_ID" + } + } + } + } + } + } + """ + + val request = UnregisterPushTokenApiRequest(API_KEY, PUSH_TOKEN, stubProfile) + compareJson(JSONObject(expectJson), JSONObject(request.requestBody!!)) + } +} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt new file mode 100644 index 000000000..945b58c1b --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt @@ -0,0 +1,253 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.analytics.model.PROFILE_ATTRIBUTES +import com.klaviyo.analytics.model.Profile +import com.klaviyo.analytics.model.ProfileKey +import com.klaviyo.fixtures.BaseTest +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +internal class KlaviyoStateTest : BaseTest() { + + private lateinit var state: KlaviyoState + + @Before + override fun setup() { + super.setup() + state = KlaviyoState() + } + + @After + override fun cleanup() { + state.reset() + super.cleanup() + } + + @Test + fun `State observers concurrency test`() = runTest { + val observer: StateObserver = { _, _ -> Thread.sleep(6) } + + state.onStateChange(observer) + + val job = launch(Dispatchers.IO) { + state.reset() + } + + val job2 = launch(Dispatchers.Default) { + withContext(Dispatchers.IO) { + Thread.sleep(8) + } + state.offStateChange(observer) + } + + job.start() + job2.start() + } + + @Test + fun `UserInfo is convertible to Profile`() { + state.externalId = EXTERNAL_ID + state.email = EMAIL + state.phoneNumber = PHONE + + val profile = state.getAsProfile() + + assert(profile.externalId == EXTERNAL_ID) + assert(profile.email == EMAIL) + assert(profile.phoneNumber == PHONE) + assert(profile.anonymousId == state.anonymousId) + assert(profile.toMap().count() == 4) // shouldn't contain any extras + + assertEquals(EXTERNAL_ID, state.externalId) + assertEquals(EMAIL, state.email) + assertEquals(PHONE, state.phoneNumber) + } + + @Test + fun `Create and store a new UUID if one does not exists in data store`() { + val anonId = state.anonymousId + val fetched = spyDataStore.fetch(ProfileKey.ANONYMOUS_ID.name) + assertEquals(anonId, fetched) + } + + @Test + fun `Do not create new UUID if one exists in data store`() { + spyDataStore.store(ProfileKey.ANONYMOUS_ID.name, ANON_ID) + assertEquals(ANON_ID, state.anonymousId) + } + + @Test + fun `Only read properties from data store once`() { + spyDataStore.store(ProfileKey.ANONYMOUS_ID.name, ANON_ID) + spyDataStore.store(ProfileKey.EXTERNAL_ID.name, EXTERNAL_ID) + spyDataStore.store(ProfileKey.EMAIL.name, EMAIL) + spyDataStore.store(ProfileKey.PHONE_NUMBER.name, PHONE) + + state.anonymousId + assertEquals(ANON_ID, state.anonymousId) + verify(exactly = 1) { spyDataStore.fetch(ProfileKey.ANONYMOUS_ID.name) } + + state.externalId + assertEquals(EXTERNAL_ID, state.externalId) + verify(exactly = 1) { spyDataStore.fetch(ProfileKey.EXTERNAL_ID.name) } + + state.email + assertEquals(EMAIL, state.email) + verify(exactly = 1) { spyDataStore.fetch(ProfileKey.EMAIL.name) } + + state.phoneNumber + assertEquals(PHONE, state.phoneNumber) + verify(exactly = 1) { spyDataStore.fetch(ProfileKey.PHONE_NUMBER.name) } + } + + @Test + fun `Anonymous ID lifecycle`() { + // Should be null after a reset... + val initialAnonId = spyDataStore.fetch(ProfileKey.ANONYMOUS_ID.name) + assertNull(initialAnonId) + + // Start tracking a new anon ID and it should be persisted + val firstAnonId = state.anonymousId + assertEquals(firstAnonId, spyDataStore.fetch(ProfileKey.ANONYMOUS_ID.name)) + + // Reset again should nullify in data store + state.reset() + assertNull(spyDataStore.fetch(ProfileKey.ANONYMOUS_ID.name)) + + // Start tracking again should generate another new anon ID + val newAnonId = state.anonymousId + assertNotEquals(firstAnonId, newAnonId) + assertEquals(newAnonId, spyDataStore.fetch(ProfileKey.ANONYMOUS_ID.name)) + } + + @Test + fun `Broadcasts change of property with key and old value`() { + spyDataStore.store(ProfileKey.EXTERNAL_ID.name, EXTERNAL_ID) + spyDataStore.store(ProfileKey.EMAIL.name, EMAIL) + spyDataStore.store(ProfileKey.PHONE_NUMBER.name, PHONE) + + var broadcastKey: Keyword? = null + var broadcastValue: String? = null + + state.onStateChange { k, v -> + broadcastKey = k + broadcastValue = v.toString() + } + + state.externalId = "new_external_id" + assertEquals(ProfileKey.EXTERNAL_ID, broadcastKey) + assertEquals(EXTERNAL_ID, broadcastValue) + + state.email = "new@email.com" + assertEquals(ProfileKey.EMAIL, broadcastKey) + assertEquals(EMAIL, broadcastValue) + + state.phoneNumber = "new_phone" + assertEquals(ProfileKey.PHONE_NUMBER, broadcastKey) + assertEquals(PHONE, broadcastValue) + } + + @Test + fun `Broadcasts on set attributes`() { + var broadcastKey: Keyword? = null + var broadcastValue: Any? = null + val customKey = ProfileKey.CUSTOM("color") + + state.onStateChange { k, v -> + broadcastKey = k + broadcastValue = v + } + + state.setAttribute(ProfileKey.FIRST_NAME, "Kermit") + state.setAttribute(customKey, "Green") + state.setAttribute(ProfileKey.LAST_NAME, "Frog") + + assertEquals(PROFILE_ATTRIBUTES, broadcastKey) + assertEquals("Kermit", (broadcastValue as? Profile)?.get(ProfileKey.FIRST_NAME)) + assertEquals("Green", (broadcastValue as? Profile)?.get(customKey)) + } + + @Test + fun `Set attributes does not set on non-string profile info`() { + var broadcastKey: Keyword? = null + var broadcastValue: Any? = null + + state.onStateChange { k, v -> + broadcastKey = k + broadcastValue = v + } + + // expecting an string but sending an int, should not be set + state.setAttribute(ProfileKey.EMAIL, 29864) + state.setAttribute(ProfileKey.LAST_NAME, "Frog") + + assertEquals(PROFILE_ATTRIBUTES, broadcastKey) + assertEquals(null, (broadcastValue as? Profile)?.get(ProfileKey.EMAIL)) + } + + @Test + fun `Broadcasts on reset attributes`() { + var broadcastKey: Keyword? = null + var broadcastValue: Any? = null + + state.onStateChange { k, v -> + broadcastKey = k + broadcastValue = v + } + + state.resetAttributes() + + assertEquals(PROFILE_ATTRIBUTES, broadcastKey) + assertNull(broadcastValue) + } + + @Test + fun `Broadcasts on reset profile`() { + state.externalId = EXTERNAL_ID + state.email = EMAIL + state.phoneNumber = PHONE + state.setAttribute(ProfileKey.FIRST_NAME, "Kermit") + state.setAttribute(ProfileKey.LAST_NAME, "Frog") + + var broadcastKey: Keyword? = null + var broadcastValue: Any? = null + + state.onStateChange { k, v -> + broadcastKey = k + broadcastValue = v + } + + state.reset() + + val broadcastProfile = broadcastValue as Profile + + assertEquals(null, broadcastKey) + assertEquals(EXTERNAL_ID, broadcastProfile.externalId) + assertEquals(EMAIL, broadcastProfile.email) + assertEquals(PHONE, broadcastProfile.phoneNumber) + assertEquals("Kermit", broadcastProfile[ProfileKey.FIRST_NAME]) + assertEquals("Frog", broadcastProfile[ProfileKey.LAST_NAME]) + } + + @Test + fun `Resetting profile email and phone number values`() { + state.email = EMAIL + state.phoneNumber = PHONE + + state.resetEmail() + state.resetPhoneNumber() + + assertEquals(state.email, null) + assertEquals(state.phoneNumber, null) + } +} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableProfileTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableProfileTest.kt new file mode 100644 index 000000000..f5f60d44b --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableProfileTest.kt @@ -0,0 +1,129 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.PROFILE_ATTRIBUTES +import com.klaviyo.analytics.model.ProfileKey +import com.klaviyo.fixtures.BaseTest +import io.mockk.verify +import java.io.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +class PersistentObservableProfileTest : BaseTest() { + + @Test + fun `Deserializes profile attributes from disk`() { + spyDataStore.store( + "attributes", + """ + { + "first_name": "Kermit", + "string": "str", + "number": 1, + "double": 1.0, + "bool": true, + "array": [ + 1, + "2", + { + "k": "v" + } + ], + "object": { + "string": "str", + "number": 1, + "double": 1.0, + "bool": true, + "sub_object": {"abc": "xyz"}, + "sub_array": [ + "test", + 2 + ] + } + } + """.trimIndent() + ) + + val delegatedProfile by PersistentObservableProfile( + PROFILE_ATTRIBUTES + ) + + val profile = delegatedProfile?.copy() + + if (profile == null) { + fail("Profile was not fetched from data store") + return + } + + assertEquals(7, profile.attributes.propertyCount()) + assertEquals("Kermit", profile[ProfileKey.FIRST_NAME]) + assertEquals("str", profile[ProfileKey.CUSTOM("string")]) + assertEquals(1, profile[ProfileKey.CUSTOM("number")]) + assertEquals(1.0.toBigDecimal(), profile[ProfileKey.CUSTOM("double")]) + assertEquals(true, profile[ProfileKey.CUSTOM("bool")]) + val array = profile[ProfileKey.CUSTOM("array")] + val obj = profile[ProfileKey.CUSTOM("object")] + + if (array is Array<*> && array.isArrayOf()) { + assertEquals(1, array[0]) + assertEquals("2", array[1]) + val subObj = array[2] + + if (subObj is HashMap<*, *>) { + assertEquals("v", subObj["k"]) + } else { + fail("Object did not decode") + } + } else { + fail("Array did not decode") + } + + if (obj is HashMap<*, *>) { + assertEquals("str", obj["string"]) + assertEquals(1, obj["number"]) + assertEquals(1.0.toBigDecimal(), obj["double"]) + assertEquals(true, obj["bool"]) + val subObj = obj["sub_object"] + val subArray = obj["sub_array"] + + if (subObj is HashMap<*, *>) { + assertEquals("xyz", subObj["abc"]) + } else { + fail("Sub-object did not decode") + } + + if (subArray is Array<*> && subArray.isArrayOf()) { + assertEquals("test", subArray[0]) + assertEquals(2, subArray[1]) + } else { + fail("Sub-array did not decode") + } + } else { + fail("Object did not decode") + } + } + + @Test + fun `Catches bad profile attributes persisted to disk`() { + spyDataStore.store( + "attributes", + """invalid_json""".trimIndent() + ) + + val profile = KlaviyoState().getAsProfile(withAttributes = true) + assertEquals(0, profile.attributes.propertyCount()) + verify { spyLog.warning(any(), any()) } + } + + @Test + fun `Catches bad json persisted to disk`() { + spyDataStore.store( + "attributes", + """{]""".trimIndent() + ) + + val profile = KlaviyoState().getAsProfile(withAttributes = true) + assertEquals(0, profile.attributes.propertyCount()) + verify { spyLog.warning(any(), any()) } + } +} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableStringTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableStringTest.kt new file mode 100644 index 000000000..1fc34985b --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableStringTest.kt @@ -0,0 +1,158 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.ProfileKey +import com.klaviyo.fixtures.BaseTest +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +internal class PersistentObservableStringTest : BaseTest() { + + companion object { + const val KEY = "test_key" + } + + @Before + override fun setup() { + super.setup() + } + + @Test + fun `Basic set and get`() { + var delegatedProperty by PersistentObservableString(ProfileKey.CUSTOM(KEY)) + assertNull(delegatedProperty) + delegatedProperty = "test_value" + assertEquals("test_value", delegatedProperty) + } + + @Test + fun `Reads from and writes to persistent store`() { + spyDataStore.store(KEY, "value") + var delegatedProperty by PersistentObservableString(ProfileKey.CUSTOM(KEY)) + assertEquals("value", delegatedProperty) + delegatedProperty = "new_value" + verify(exactly = 1) { spyDataStore.fetch(KEY) } + verify(exactly = 1) { spyDataStore.store(KEY, "new_value") } + assertEquals("new_value", spyDataStore.fetch(KEY)) + } + + @Test + fun `Uses fallback if persistent store is empty`() { + val delegatedProperty by PersistentObservableString(ProfileKey.CUSTOM(KEY)) { "fallback" } + assertEquals("fallback", delegatedProperty) + assertEquals("fallback", spyDataStore.fetch(KEY)) + verify(exactly = 1) { spyDataStore.store(KEY, "fallback") } + } + + @Test + fun `Invokes callback when value changes`() { + var invokedWithProperty: PersistentObservableProperty? = null + var invokedWithOldValue: String? = null + val backingProp = PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { property, oldValue -> + invokedWithProperty = property + invokedWithOldValue = oldValue + } + ) + + var delegatedProperty by backingProp + + assertNull(delegatedProperty) + assertNull(invokedWithProperty) + assertNull(invokedWithOldValue) + + delegatedProperty = "1" + + assertEquals("1", delegatedProperty) + assertEquals(backingProp, invokedWithProperty) + assertNull(invokedWithOldValue) + + delegatedProperty = "2" + + assertEquals("2", delegatedProperty) + assertEquals(backingProp, invokedWithProperty) + assertEquals("1", invokedWithOldValue) + } + + @Test + fun `Invokes callback with persisted value on first change`() { + spyDataStore.store(KEY, "abc123") + var invokedWithOldValue: String? = null + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { _, oldValue -> + invokedWithOldValue = oldValue + } + ) + + delegatedProperty = "xyz789" + + assertEquals("xyz789", delegatedProperty) + assertEquals("abc123", invokedWithOldValue) + } + + @Test + fun `Does not store or invoke callback when value is unchanged`() { + spyDataStore.store(KEY, "value") + var invoked = false + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { _, _ -> invoked = true } + ) + + delegatedProperty = "value" + + assertFalse(invoked) + verify(exactly = 1) { spyDataStore.store(KEY, "value") } + } + + @Test + fun `Whitespace is trimmed prior to validation`() { + spyDataStore.store(KEY, "value") + var invoked = false + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { _, _ -> invoked = true } + ) + + delegatedProperty = " value " + + assertFalse(invoked) + verify(exactly = 1) { spyDataStore.store(KEY, "value") } + } + + @Test + fun `Empty string or null is ignored by primary setter method`() { + spyDataStore.store(KEY, "value") + var invoked = false + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { _, _ -> invoked = true } + ) + + delegatedProperty = "" + delegatedProperty = null + + assertFalse(invoked) + verify(exactly = 1) { spyDataStore.store(KEY, "value") } + } + + @Test + fun `Resets value without invoking callback`() { + spyDataStore.store(KEY, "value") + var invoked = false + val property = PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { _, _ -> invoked = true } + ) + val delegatedProperty by property + property.reset() + assertFalse(invoked) + assertNull(delegatedProperty) + assertNull(spyDataStore.fetch(KEY)) + } +} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/state/StateSideEffectsTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/StateSideEffectsTest.kt new file mode 100644 index 000000000..9ecfbfd73 --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/StateSideEffectsTest.kt @@ -0,0 +1,343 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.PROFILE_ATTRIBUTES +import com.klaviyo.analytics.model.Profile +import com.klaviyo.analytics.model.ProfileKey +import com.klaviyo.analytics.networking.ApiClient +import com.klaviyo.analytics.networking.ApiObserver +import com.klaviyo.analytics.networking.requests.EventApiRequest +import com.klaviyo.analytics.networking.requests.KlaviyoApiRequest +import com.klaviyo.analytics.networking.requests.KlaviyoError +import com.klaviyo.analytics.networking.requests.KlaviyoErrorResponse +import com.klaviyo.analytics.networking.requests.KlaviyoErrorSource +import com.klaviyo.analytics.networking.requests.ProfileApiRequest +import com.klaviyo.analytics.networking.requests.PushTokenApiRequest +import com.klaviyo.core.Registry +import com.klaviyo.fixtures.BaseTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class StateSideEffectsTest : BaseTest() { + + private val profile = Profile(email = EMAIL) + private val capturedProfile = slot() + private val capturedApiObserver = slot() + private val capturedStateObserver = slot() + private val capturedPushState = slot() + private val apiClientMock: ApiClient = mockk().apply { + every { onApiRequest(any(), capture(capturedApiObserver)) } returns Unit + every { offApiRequest(any()) } returns Unit + every { enqueueProfile(capture(capturedProfile)) } returns Unit + every { enqueueEvent(any(), any()) } returns Unit + every { enqueuePushToken(any(), any()) } returns Unit + } + + private val stateMock = mockk().apply { + every { onStateChange(capture(capturedStateObserver)) } returns Unit + every { offStateChange(any()) } returns Unit + every { pushState = captureNullable(capturedPushState) } returns Unit + every { getAsProfile(withAttributes = any()) } returns profile + every { resetAttributes() } returns Unit + every { pushToken } returns null + } + + private val klaviyoStateMock = mockk().apply { + every { onStateChange(capture(capturedStateObserver)) } returns Unit + every { resetPhoneNumber() } returns Unit + every { resetEmail() } returns Unit + } + + @Before + override fun setup() { + super.setup() + Registry.register(apiClientMock) + } + + @After + override fun cleanup() { + Registry.unregister() + super.cleanup() + } + + @Test + fun `Subscribes on init and unsubscribes`() { + val sideEffects = StateSideEffects(stateMock, apiClientMock) + verify { stateMock.onStateChange(any()) } + verify { apiClientMock.onApiRequest(any(), any()) } + verify { mockLifecycleMonitor.onActivityEvent(any()) } + + sideEffects.detach() + verify { stateMock.offStateChange(any()) } + verify { apiClientMock.offApiRequest(any()) } + verify { mockLifecycleMonitor.offActivityEvent(any()) } + } + + @Test + fun `Profile changes enqueue a single profile API request`() { + StateSideEffects(stateMock, apiClientMock) + + capturedStateObserver.captured(ProfileKey.EMAIL, null) + capturedStateObserver.captured(PROFILE_ATTRIBUTES, null) + capturedStateObserver.captured(null, null) + + staticClock.execute(debounceTime.toLong()) + + verify(exactly = 1) { + apiClientMock.enqueueProfile( + match { + it.email == profile.email && it.propertyCount() == profile.propertyCount() + } + ) + } + } + + @Test + fun `Empty attributes do not enqueue a profile API request`() { + StateSideEffects( + stateMock.apply { + every { getAsProfile(withAttributes = any()) } returns Profile( + properties = mapOf(ProfileKey.FIRST_NAME to "Kermit") + ) + }, + apiClientMock + ) + + capturedStateObserver.captured(PROFILE_ATTRIBUTES, null) + + staticClock.execute(debounceTime.toLong()) + + verify(exactly = 1) { apiClientMock.enqueueProfile(any()) } + } + + @Test + fun `Resetting profile enqueues Profiles API call immediately`() { + StateSideEffects( + stateMock.apply { + every { getAsProfile(withAttributes = any()) } returns Profile( + properties = mapOf( + ProfileKey.ANONYMOUS_ID to ANON_ID, + ProfileKey.FIRST_NAME to "Kermit" + ) + ) + }, + apiClientMock + ) + + capturedStateObserver.captured(PROFILE_ATTRIBUTES, null) + + every { stateMock.getAsProfile(withAttributes = any()) } returns Profile( + properties = mapOf( + ProfileKey.ANONYMOUS_ID to "new_anon_id" + ) + ) + + capturedStateObserver.captured(null, null) + + verify(exactly = 1) { apiClientMock.enqueueProfile(any()) } + + staticClock.execute(debounceTime.toLong()) + + verify(exactly = 2) { apiClientMock.enqueueProfile(any()) } + } + + @Test + fun `Resetting profile enqueues Push Token API call immediately when push token is in state`() { + every { stateMock.pushToken } returns PUSH_TOKEN + + StateSideEffects( + stateMock.apply { + every { getAsProfile(withAttributes = any()) } returns Profile( + properties = mapOf( + ProfileKey.ANONYMOUS_ID to ANON_ID, + ProfileKey.FIRST_NAME to "Kermit" + ) + ) + }, + apiClientMock + ) + + capturedStateObserver.captured(PROFILE_ATTRIBUTES, null) + + every { stateMock.getAsProfile(withAttributes = any()) } returns Profile( + properties = mapOf( + ProfileKey.ANONYMOUS_ID to "new_anon_id" + ) + ) + + capturedStateObserver.captured(null, null) + + verify(exactly = 1) { apiClientMock.enqueuePushToken(PUSH_TOKEN, any()) } + } + + @Test + fun `Attributes do enqueue a profile API request`() { + StateSideEffects(stateMock, apiClientMock) + + capturedStateObserver.captured(PROFILE_ATTRIBUTES, null) + + staticClock.execute(debounceTime.toLong()) + + verify(exactly = 0) { apiClientMock.enqueueProfile(any()) } + } + + @Test + fun `Push state change enqueues an API request`() { + every { stateMock.pushState } returns "stateful" + every { stateMock.pushToken } returns "token" + + StateSideEffects(stateMock, apiClientMock) + + capturedStateObserver.captured(ProfileKey.PUSH_STATE, null) + verify(exactly = 1) { apiClientMock.enqueuePushToken("token", profile) } + } + + @Test + fun `Empty push state is ignored`() { + every { stateMock.pushState } returns "" + + StateSideEffects(stateMock, apiClientMock) + + capturedStateObserver.captured(ProfileKey.PUSH_STATE, null) + verify(exactly = 0) { apiClientMock.enqueuePushToken(any(), any()) } + } + + @Test + fun `Push token change alone does not trigger an API request`() { + every { stateMock.pushState } returns "stateful" + every { stateMock.pushToken } returns "token" + + StateSideEffects(stateMock, apiClientMock) + + capturedStateObserver.captured(ProfileKey.PUSH_TOKEN, null) + verify(exactly = 0) { apiClientMock.enqueuePushToken(any(), any()) } + } + + @Test + fun `Reset push state on push API failure`() { + StateSideEffects(stateMock, apiClientMock) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + every { responseCode } returns 412 + } + ) + + assertNull(capturedPushState.captured) + } + + @Test + fun `Invalid input on phone number resets field`() { + Registry.register(klaviyoStateMock) + StateSideEffects( + state = klaviyoStateMock, + apiClient = apiClientMock + ) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + every { responseCode } returns 400 + every { errorBody } returns KlaviyoErrorResponse( + listOf( + KlaviyoError( + id = "67ed6dbf-1653-499b-a11d-30310aa01ff7", + status = 400, + title = "Invalid input.", + detail = "Invalid phone number format (Example of a valid format: +12345678901)", + source = KlaviyoErrorSource( + pointer = "/data/attributes/phone_number" + ) + ) + ) + ) + } + ) + + verify { klaviyoStateMock.resetPhoneNumber() } + Registry.unregister() + } + + @Test + fun `Invalid input on email resets field`() { + Registry.register(klaviyoStateMock) + StateSideEffects( + state = klaviyoStateMock, + apiClient = apiClientMock + ) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + every { responseCode } returns 400 + every { errorBody } returns KlaviyoErrorResponse( + listOf( + KlaviyoError( + id = "4f739784-390b-4df3-acd8-6eb07d60e6b4", + status = 400, + title = "Invalid input.", + detail = "Invalid email address", + source = KlaviyoErrorSource( + pointer = "/data/attributes/email" + ) + ) + ) + ) + } + ) + + verify { klaviyoStateMock.resetEmail() } + Registry.unregister() + } + + @Test + fun `Empty error body does not reset fields`() { + Registry.register(klaviyoStateMock) + StateSideEffects( + state = klaviyoStateMock, + apiClient = apiClientMock + ) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + every { responseCode } returns 400 + every { errorBody } returns KlaviyoErrorResponse( + listOf() + ) + } + ) + + verify(exactly = 0) { klaviyoStateMock.resetEmail() } + verify(exactly = 0) { klaviyoStateMock.resetEmail() } + Registry.unregister() + } + + @Test + fun `Other API failures do not affect push state`() { + StateSideEffects(stateMock, apiClientMock) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + every { responseCode } returns 412 + } + ) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + every { responseCode } returns 412 + } + ) + + assertFalse(capturedPushState.isCaptured) + } +} diff --git a/sdk/build.gradle b/sdk/build.gradle index 45eb94db5..a14f8b580 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -18,13 +18,15 @@ subprojects { localProperties.load(new FileInputStream(rootProject.file("local.properties"))) } def serverTarget = localProperties["localKlaviyoServerUrl"] ?: klaviyoServerUrl + def apiRevision = localProperties["localKlaviyoApiRevision"] ?: klaviyoApiRevision defaultConfig { minSdkVersion versionFor(project, "version.android.minSdk") as Integer targetSdkVersion versionFor(project, "version.android.targetSdk") as Integer testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" versionCode versionFor(project, "version.klaviyo.versionCode") as Integer - versionName versionFor(project, "version.klaviyo.versionName") as String + versionName readXmlValue('src/main/res/values/strings.xml','klaviyo_sdk_version_override', project(':sdk:core')) + consumerProguardFiles("consumer-rules.pro") } buildFeatures { @@ -45,12 +47,12 @@ subprojects { minifyEnabled false proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" buildConfigField "String", "KLAVIYO_SERVER_URL", "\"${serverTarget}\"" - buildConfigField "String", "VERSION", "\"${versionFor(project, "version.klaviyo.versionName")}\"" + buildConfigField "String", "KLAVIYO_API_REVISION", "\"${apiRevision}\"" } debug { debuggable = true buildConfigField "String", "KLAVIYO_SERVER_URL", "\"${serverTarget}\"" - buildConfigField "String", "VERSION", "\"${versionFor(project, "version.klaviyo.versionName")}\"" + buildConfigField "String", "KLAVIYO_API_REVISION", "\"${apiRevision}\"" } } diff --git a/sdk/core/build.gradle b/sdk/core/build.gradle index 79868e695..402fdbf3f 100644 --- a/sdk/core/build.gradle +++ b/sdk/core/build.gradle @@ -1,4 +1,3 @@ -import static de.fayard.refreshVersions.core.Versions.versionFor dependencies { testImplementation project(":sdk:fixtures") @@ -30,7 +29,7 @@ afterEvaluate { from components[ext.publishBuildVariant] groupId = klaviyoGroupId artifactId = "core" - version = versionFor(project, "version.klaviyo.versionName") + version = readXmlValue('src/main/res/values/strings.xml','klaviyo_sdk_version_override', project) } } } diff --git a/sdk/core/src/main/java/com/klaviyo/core/KLog.kt b/sdk/core/src/main/java/com/klaviyo/core/KLog.kt index 29b8ab6f1..564dba697 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/KLog.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/KLog.kt @@ -6,7 +6,26 @@ import com.klaviyo.core.config.Log import com.klaviyo.core.config.Log.Level import java.util.regex.Pattern -open class KLog : Log { +object KLog : Log { + + /** + * Log level key for manifest, specifying the minimum log level to output, see [Log.logLevel] + */ + private const val LOG_LEVEL = "com.klaviyo.core.log_level" + + private const val MAX_TAG_LENGTH = 23 + + private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$") + + private val ignoreList = listOf( + KLog::class.java.name + ) + + private val defaultLogLevel = if (BuildConfig.DEBUG) { + Level.Warning + } else { + Level.Error + } private var _logLevel: Level? = null override var logLevel: Level @@ -48,62 +67,39 @@ open class KLog : Log { } } - private companion object { - /** - * Log level key for manifest, specifying the minimum log level to output, see [Log.logLevel] - */ - private const val LOG_LEVEL = "com.klaviyo.core.log_level" - - private val defaultLogLevel = if (BuildConfig.DEBUG) { - Level.Warning - } else { - Level.Error - } - - /** - * Inspired from reading through Timber source code - * We really don't need the full dependency though - */ - - private const val MAX_TAG_LENGTH = 23 - private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$") - - private val ignoreList = listOf( - KLog::class.java.name, - Companion::class.java.name - ) - - /** - * Extract the tag which should be used for the message from the `element`. By default - * this will use the class name without any anonymous class suffixes (e.g., `Foo$1` - * becomes `Foo`). - * - * NOTE: This will not be called if a manual tag is specified - */ - private fun makeTag(): String = Throwable().stackTrace - .first { it.className !in ignoreList && !it.className.contains(Log::class.java.name) } - .let { element -> - var tag = element.className.substringAfterLast('.') - tag = if (element.methodName == "invoke") "$tag:${element.lineNumber}" else tag - - val m = ANONYMOUS_CLASS.matcher(tag) - if (m.find()) { - tag = m.replaceAll("") - } - - // Make sure tag contains our name - if (!tag.lowercase().contains("klaviyo")) { - tag = "Klaviyo.$tag" - } + /** + * Inspired from reading through Timber source code + * We really don't need the full dependency though + * + * Extract the tag which should be used for the message from the `element`. By default, + * this will use the class name without any anonymous class suffixes (e.g., `Foo$1` + * becomes `Foo`). + * + * NOTE: This will not be called if a manual tag is specified + */ + private fun makeTag(): String = Throwable().stackTrace + .first { it.className !in ignoreList && !it.className.contains(Log::class.java.name) } + .let { element -> + var tag = element.className.substringAfterLast('.') + tag = if (element.methodName == "invoke") "$tag:${element.lineNumber}" else tag + + val m = ANONYMOUS_CLASS.matcher(tag) + if (m.find()) { + tag = m.replaceAll("") + } - // Tag length limit was removed in API 26. - tag = if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { - tag - } else { - tag.substring(0, MAX_TAG_LENGTH) - } + // Make sure tag contains our name + if (!tag.lowercase().contains("klaviyo")) { + tag = "Klaviyo.$tag" + } - return tag + // Tag length limit was removed in API 26. + tag = if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { + tag + } else { + tag.substring(0, MAX_TAG_LENGTH) } - } + + return tag + } } diff --git a/sdk/core/src/main/java/com/klaviyo/core/Registry.kt b/sdk/core/src/main/java/com/klaviyo/core/Registry.kt index 05e3039b8..755e13a37 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/Registry.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/Registry.kt @@ -37,11 +37,21 @@ typealias Registration = () -> Any */ object Registry { + /** + * Shortcut to the registered [Config] + * + * @throws [MissingConfig] if uninitialized + */ + val config: Config get() = get() + + /** + * Access to [Config.Builder] for registering new or updated SDK configuration + */ val configBuilder: Config.Builder get() = KlaviyoConfig.Builder() - val config: Config get() = get() + val clock: Clock get() = SystemClock - val clock: Clock = SystemClock + val log: Log get() = KLog val lifecycleMonitor: LifecycleMonitor get() = KlaviyoLifecycleMonitor @@ -51,8 +61,6 @@ object Registry { val dataStore: DataStore get() = SharedPreferencesDataStore - val log: Log get() = get() - /** * Internal registry of registered service instances */ @@ -65,10 +73,6 @@ object Registry { @PublishedApi internal val registry = mutableMapOf() - init { - register { KLog() } - } - /** * Remove registered service by type, specified by generic parameter * @@ -115,6 +119,19 @@ object Registry { registry.containsKey(type) || services.containsKey(type) } + /** + * Get a registered service by type, else null + * + * @param T - Type of service, usually an interface + * @return The instance of the service + */ + inline fun getOrNull(): T? { + val type = typeOf() + val service: Any? = services[type] + + return if (service is T) service else null + } + /** * Get a registered service by type * diff --git a/sdk/core/src/main/java/com/klaviyo/core/config/Clock.kt b/sdk/core/src/main/java/com/klaviyo/core/config/Clock.kt index e512b1723..16dc3d866 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/config/Clock.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/config/Clock.kt @@ -4,7 +4,7 @@ interface Clock { fun currentTimeMillis(): Long - fun isoTime(milliseconds: Long = SystemClock.currentTimeMillis()): String + fun isoTime(milliseconds: Long = currentTimeMillis()): String fun schedule(delay: Long, task: () -> Unit): Cancellable diff --git a/sdk/core/src/main/java/com/klaviyo/core/config/Config.kt b/sdk/core/src/main/java/com/klaviyo/core/config/Config.kt index 5b82c4d91..e8da4440d 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/config/Config.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/config/Config.kt @@ -6,6 +6,9 @@ import com.klaviyo.core.networking.NetworkMonitor interface Config { val isDebugBuild: Boolean val baseUrl: String + val apiRevision: String + val sdkName: String + val sdkVersion: String val apiKey: String val applicationContext: Context @@ -25,6 +28,7 @@ interface Config { fun apiKey(apiKey: String): Builder fun applicationContext(context: Context): Builder fun baseUrl(baseUrl: String): Builder + fun apiRevision(apiRevision: String): Builder fun debounceInterval(debounceInterval: Int): Builder fun networkTimeout(networkTimeout: Int): Builder fun networkFlushInterval(networkFlushInterval: Long, type: NetworkMonitor.NetworkType): Builder diff --git a/sdk/core/src/main/java/com/klaviyo/core/config/KlaviyoConfig.kt b/sdk/core/src/main/java/com/klaviyo/core/config/KlaviyoConfig.kt index 3f6e9f317..c7fe3873a 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/config/KlaviyoConfig.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/config/KlaviyoConfig.kt @@ -10,6 +10,7 @@ import android.os.Bundle import androidx.core.content.PackageManagerCompat import com.klaviyo.core.BuildConfig import com.klaviyo.core.KlaviyoException +import com.klaviyo.core.R import com.klaviyo.core.Registry import com.klaviyo.core.networking.NetworkMonitor @@ -94,6 +95,10 @@ object KlaviyoConfig : Config { override var baseUrl: String = BuildConfig.KLAVIYO_SERVER_URL private set + override var apiRevision: String = BuildConfig.KLAVIYO_API_REVISION + private set + override lateinit var sdkName: String private set + override lateinit var sdkVersion: String private set override lateinit var apiKey: String private set override lateinit var applicationContext: Context private set override var debounceInterval = DEBOUNCE_INTERVAL @@ -114,11 +119,12 @@ object KlaviyoConfig : Config { private set override val networkJitterRange = 0..10 - override fun getManifestInt(key: String, defaultValue: Int): Int = if (!this::applicationContext.isInitialized) { - defaultValue - } else { - applicationContext.getManifestInt(key, defaultValue) - } + override fun getManifestInt(key: String, defaultValue: Int): Int = + if (!this::applicationContext.isInitialized) { + defaultValue + } else { + applicationContext.getManifestInt(key, defaultValue) + } /** * Nested class to enable the builder pattern for easy declaration of custom configurations @@ -127,6 +133,9 @@ object KlaviyoConfig : Config { private var apiKey: String = "" private var applicationContext: Context? = null private var baseUrl: String? = null + private var apiRevision: String? = null + private var sdkName: String? = null + private var sdkVersion: String? = null private var debounceInterval: Int = DEBOUNCE_INTERVAL private var networkTimeout: Int = NETWORK_TIMEOUT_DEFAULT private var networkFlushIntervals = longArrayOf( @@ -155,6 +164,10 @@ object KlaviyoConfig : Config { this.baseUrl = baseUrl } + override fun apiRevision(apiRevision: String): Config.Builder = apply { + this.apiRevision = apiRevision + } + override fun debounceInterval(debounceInterval: Int) = apply { if (debounceInterval >= 0) { this.debounceInterval = debounceInterval @@ -224,7 +237,6 @@ object KlaviyoConfig : Config { } val context = applicationContext ?: throw MissingContext() - val packageInfo = context.packageManager.getPackageInfoCompat( context.packageName, PackageManager.GET_PERMISSIONS @@ -232,6 +244,11 @@ object KlaviyoConfig : Config { packageInfo.assertRequiredPermissions(requiredPermissions) baseUrl?.let { KlaviyoConfig.baseUrl = it } + KlaviyoConfig.sdkVersion = context.resources.getString( + R.string.klaviyo_sdk_version_override + ) + KlaviyoConfig.sdkName = context.resources.getString(R.string.klaviyo_sdk_name_override) + apiRevision?.let { KlaviyoConfig.apiRevision = it } KlaviyoConfig.apiKey = apiKey KlaviyoConfig.applicationContext = context KlaviyoConfig.debounceInterval = debounceInterval diff --git a/sdk/core/src/main/java/com/klaviyo/core/config/SystemClock.kt b/sdk/core/src/main/java/com/klaviyo/core/config/SystemClock.kt index f967de4f7..ff2d8fecd 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/config/SystemClock.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/config/SystemClock.kt @@ -1,5 +1,6 @@ package com.klaviyo.core.config +import android.annotation.SuppressLint import java.text.SimpleDateFormat import java.util.Date import java.util.TimeZone @@ -8,6 +9,7 @@ import kotlin.concurrent.schedule internal object SystemClock : Clock { + @SuppressLint("SimpleDateFormat") private val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").apply { timeZone = TimeZone.getTimeZone("UTC") } diff --git a/sdk/core/src/main/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitor.kt b/sdk/core/src/main/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitor.kt index ce01630da..20af938c5 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitor.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitor.kt @@ -17,10 +17,6 @@ internal object KlaviyoLifecycleMonitor : LifecycleMonitor, Application.Activity mutableListOf() ) - init { - onActivityEvent { Registry.log.verbose(it.type) } - } - override fun onActivityEvent(observer: ActivityObserver) { activityObservers += observer } @@ -30,6 +26,7 @@ internal object KlaviyoLifecycleMonitor : LifecycleMonitor, Application.Activity } private fun broadcastEvent(event: ActivityEvent) { + Registry.log.verbose(event.type) synchronized(activityObservers) { activityObservers.forEach { it(event) } } diff --git a/sdk/core/src/main/java/com/klaviyo/core/lifecycle/NoOpLifecycleCallbacks.kt b/sdk/core/src/main/java/com/klaviyo/core/lifecycle/NoOpLifecycleCallbacks.kt deleted file mode 100644 index 62230eb08..000000000 --- a/sdk/core/src/main/java/com/klaviyo/core/lifecycle/NoOpLifecycleCallbacks.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.klaviyo.core.lifecycle - -import android.app.Activity -import android.app.Application -import android.os.Bundle - -/** - * A no-op implementation of ActivityLifecycleCallbacks - * to temporarily replace the public property Klaviyo.lifecycleCallbacks - * and prevent duplicate registration of the KlaviyoLifecycleMonitor - * until next major release when we can make the breaking change to remove that public property - */ -object NoOpLifecycleCallbacks : Application.ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} - override fun onActivityStarted(activity: Activity) {} - override fun onActivityResumed(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) {} - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityDestroyed(activity: Activity) {} -} diff --git a/sdk/core/src/main/java/com/klaviyo/core/model/SharedPreferencesDataStore.kt b/sdk/core/src/main/java/com/klaviyo/core/model/SharedPreferencesDataStore.kt index 2bb37d3d9..1273c5c2c 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/model/SharedPreferencesDataStore.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/model/SharedPreferencesDataStore.kt @@ -23,10 +23,6 @@ internal object SharedPreferencesDataStore : DataStore { mutableListOf() ) - init { - onStoreChange { key, value -> Registry.log.verbose("$key=$value") } - } - override fun onStoreChange(observer: StoreObserver) { storeObservers += observer } @@ -36,6 +32,7 @@ internal object SharedPreferencesDataStore : DataStore { } private fun broadcastStoreChange(key: String, value: String?) { + Registry.log.verbose("$key=$value") synchronized(storeObservers) { storeObservers.forEach { it(key, value) } } diff --git a/sdk/core/src/main/java/com/klaviyo/core/networking/KlaviyoNetworkMonitor.kt b/sdk/core/src/main/java/com/klaviyo/core/networking/KlaviyoNetworkMonitor.kt index 37d9e72a6..83f8203cb 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/networking/KlaviyoNetworkMonitor.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/networking/KlaviyoNetworkMonitor.kt @@ -39,23 +39,12 @@ internal object KlaviyoNetworkMonitor : NetworkMonitor { override fun onUnavailable() = broadcastNetworkChange() - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities - ) = broadcastNetworkChange() - override fun onLinkPropertiesChanged( network: Network, linkProperties: LinkProperties ) = broadcastNetworkChange() } - init { - onNetworkChange { - Registry.log.verbose("Network ${if (it) "available" else "unavailable"}") - } - } - /** * Register an observer to be notified when network connectivity has changed * @@ -80,6 +69,9 @@ internal object KlaviyoNetworkMonitor : NetworkMonitor { */ private fun broadcastNetworkChange() { val isConnected = isNetworkConnected() + + Registry.log.verbose("Network ${if (isConnected) "available" else "unavailable"}") + synchronized(networkChangeObservers) { networkChangeObservers.forEach { it(isConnected) } } diff --git a/sdk/core/src/main/res/values/strings.xml b/sdk/core/src/main/res/values/strings.xml new file mode 100644 index 000000000..f9da2d18a --- /dev/null +++ b/sdk/core/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + android + 3.0.0 + \ No newline at end of file diff --git a/sdk/core/src/test/java/com/klaviyo/core/KLogTest.kt b/sdk/core/src/test/java/com/klaviyo/core/KLogTest.kt index 42d881ce5..19412c9e4 100644 --- a/sdk/core/src/test/java/com/klaviyo/core/KLogTest.kt +++ b/sdk/core/src/test/java/com/klaviyo/core/KLogTest.kt @@ -32,35 +32,34 @@ class KLogTest { for (level in Level.entries) { setup() - val log = KLog() - log.logLevel = level + KLog.logLevel = level - log.verbose("verbose") + KLog.verbose("verbose") verify(inverse = level == Level.None || level.ordinal > Level.Verbose.ordinal) { Level.Verbose.log(any(), any(), any()) } - log.debug("debug") + KLog.debug("debug") verify(inverse = level == Level.None || level.ordinal > Level.Debug.ordinal) { Level.Debug.log(any(), any(), any()) } - log.info("info") + KLog.info("info") verify(inverse = level == Level.None || level.ordinal > Level.Info.ordinal) { Level.Info.log(any(), any(), any()) } - log.warning("warning") + KLog.warning("warning") verify(inverse = level == Level.None || level.ordinal > Level.Warning.ordinal) { Level.Warning.log(any(), any(), any()) } - log.error("error") + KLog.error("error") verify(inverse = level == Level.None || level.ordinal > Level.Error.ordinal) { Level.Error.log(any(), any(), any()) } - log.wtf("wtf") + KLog.wtf("wtf") verify(inverse = level == Level.None || level.ordinal > Level.Assert.ordinal) { Level.Assert.log(any(), any(), any()) } @@ -69,9 +68,8 @@ class KLogTest { @Test fun `Log tag contains Klaviyo`() { - val log = KLog() - log.logLevel = Level.Verbose - log.verbose("msg") + KLog.logLevel = Level.Verbose + KLog.verbose("msg") verify { Level.Verbose.log(match { it.contains("Klaviyo") }, any(), any()) } } } diff --git a/sdk/core/src/test/java/com/klaviyo/core/config/KlaviyoConfigTest.kt b/sdk/core/src/test/java/com/klaviyo/core/config/KlaviyoConfigTest.kt index 97bcbcecb..dcf5b9d17 100644 --- a/sdk/core/src/test/java/com/klaviyo/core/config/KlaviyoConfigTest.kt +++ b/sdk/core/src/test/java/com/klaviyo/core/config/KlaviyoConfigTest.kt @@ -5,6 +5,7 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import com.klaviyo.core.BuildConfig +import com.klaviyo.core.R import com.klaviyo.core.Registry import com.klaviyo.core.networking.NetworkMonitor import com.klaviyo.fixtures.BaseTest @@ -39,8 +40,12 @@ internal class KlaviyoConfigTest : BaseTest() { super.setup() mockkStatic(PackageManager.PackageInfoFlags::class) every { PackageManager.PackageInfoFlags.of(any()) } returns mockPackageManagerFlags - every { contextMock.packageManager } returns mockPackageManager - every { contextMock.packageName } returns BuildConfig.LIBRARY_PACKAGE_NAME + every { mockContext.packageManager } returns mockPackageManager + every { mockContext.packageName } returns BuildConfig.LIBRARY_PACKAGE_NAME + every { mockContext.resources } returns mockk { + every { getString(R.string.klaviyo_sdk_name_override) } returns "android" + every { getString(R.string.klaviyo_sdk_version_override) } returns "9.9.9" + } every { mockPackageManager.getPackageInfo( BuildConfig.LIBRARY_PACKAGE_NAME, @@ -58,7 +63,7 @@ internal class KlaviyoConfigTest : BaseTest() { fun `KlaviyoConfig Builder sets variables successfully`() { KlaviyoConfig.Builder() .apiKey(API_KEY) - .applicationContext(contextMock) + .applicationContext(mockContext) .baseUrl("fakeurl") .debounceInterval(1) .networkTimeout(2) @@ -71,7 +76,7 @@ internal class KlaviyoConfigTest : BaseTest() { .build() assertEquals(API_KEY, KlaviyoConfig.apiKey) - assertEquals(contextMock, KlaviyoConfig.applicationContext) + assertEquals(mockContext, KlaviyoConfig.applicationContext) assertEquals("fakeurl", KlaviyoConfig.baseUrl) assertEquals(1, KlaviyoConfig.debounceInterval) assertEquals(2, KlaviyoConfig.networkTimeout) @@ -90,13 +95,15 @@ internal class KlaviyoConfigTest : BaseTest() { assertEquals(4, KlaviyoConfig.networkFlushDepth) assertEquals(5, KlaviyoConfig.networkMaxAttempts) assertEquals(7, KlaviyoConfig.networkMaxRetryInterval) + assertEquals("android", KlaviyoConfig.sdkName) + assertEquals("9.9.9", KlaviyoConfig.sdkVersion) } @Test fun `KlaviyoConfig Builder missing variables uses default values successfully`() { KlaviyoConfig.Builder() .apiKey(API_KEY) - .applicationContext(contextMock) + .applicationContext(mockContext) .build() assertEquals(API_KEY, KlaviyoConfig.apiKey) @@ -117,13 +124,15 @@ internal class KlaviyoConfigTest : BaseTest() { assertEquals(25, KlaviyoConfig.networkFlushDepth) assertEquals(50, KlaviyoConfig.networkMaxAttempts) assertEquals(180_000L, KlaviyoConfig.networkMaxRetryInterval) + assertEquals("android", KlaviyoConfig.sdkName) + assertEquals("9.9.9", KlaviyoConfig.sdkVersion) } @Test fun `KlaviyoConfig Builder rejects bad values and uses default values`() { KlaviyoConfig.Builder() .apiKey(API_KEY) - .applicationContext(contextMock) + .applicationContext(mockContext) .debounceInterval(-5000) .networkTimeout(-5000) .networkFlushInterval(-5000, NetworkMonitor.NetworkType.Wifi) @@ -151,15 +160,16 @@ internal class KlaviyoConfigTest : BaseTest() { assertEquals(25, KlaviyoConfig.networkFlushDepth) assertEquals(50, KlaviyoConfig.networkMaxAttempts) assertEquals(180_000, KlaviyoConfig.networkMaxRetryInterval) - + assertEquals("android", KlaviyoConfig.sdkName) + assertEquals("9.9.9", KlaviyoConfig.sdkVersion) // Each bad call should have generated an error log - verify(exactly = 8) { logSpy.error(any(), null) } + verify(exactly = 8) { spyLog.error(any(), null) } } @Test(expected = MissingAPIKey::class) fun `KlaviyoConfig Builder missing API key throws expected exception`() { KlaviyoConfig.Builder() - .applicationContext(contextMock) + .applicationContext(mockContext) .build() } @@ -175,7 +185,7 @@ internal class KlaviyoConfigTest : BaseTest() { mockPackageInfo.requestedPermissions = arrayOf() KlaviyoConfig.Builder() .apiKey(API_KEY) - .applicationContext(contextMock) + .applicationContext(mockContext) .build() } @@ -183,7 +193,7 @@ internal class KlaviyoConfigTest : BaseTest() { fun `getPackageInfoCompat detects platform properly`() { setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), 33) mockPackageManager.getPackageInfoCompat( - contextMock.packageName, + mockContext.packageName, PackageManager.GET_PERMISSIONS ) verify { @@ -195,7 +205,7 @@ internal class KlaviyoConfigTest : BaseTest() { setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), 23) mockPackageManager.getPackageInfoCompat( - contextMock.packageName, + mockContext.packageName, PackageManager.GET_PERMISSIONS ) verify { mockPackageManager.getPackageInfo(BuildConfig.LIBRARY_PACKAGE_NAME, any()) } diff --git a/sdk/core/src/test/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitorTest.kt b/sdk/core/src/test/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitorTest.kt index e52a2fda2..effa94253 100644 --- a/sdk/core/src/test/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitorTest.kt +++ b/sdk/core/src/test/java/com/klaviyo/core/lifecycle/KlaviyoLifecycleMonitorTest.kt @@ -44,18 +44,18 @@ class KlaviyoLifecycleMonitorTest : BaseTest() { fun `Lifecycle events are logged`() { // At this time, we expect nothing from this methods: KlaviyoLifecycleMonitor.onActivityStarted(mockk()) - verify { logSpy.verbose("Started") } + verify { spyLog.verbose("Started") } KlaviyoLifecycleMonitor.onActivityCreated(mockk(), mockk()) - verify { logSpy.verbose("Created") } + verify { spyLog.verbose("Created") } KlaviyoLifecycleMonitor.onActivityResumed(mockk()) - verify { logSpy.verbose("Resumed") } + verify { spyLog.verbose("Resumed") } KlaviyoLifecycleMonitor.onActivitySaveInstanceState(mockk(), mockk()) - verify { logSpy.verbose("SaveInstanceState") } + verify { spyLog.verbose("SaveInstanceState") } KlaviyoLifecycleMonitor.onActivityPaused(mockk()) - verify { logSpy.verbose("Paused") } + verify { spyLog.verbose("Paused") } KlaviyoLifecycleMonitor.onActivityStopped(mockk()) - verify { logSpy.verbose("Stopped") } - verify { logSpy.verbose("AllStopped") } + verify { spyLog.verbose("Stopped") } + verify { spyLog.verbose("AllStopped") } } @Test diff --git a/sdk/core/src/test/java/com/klaviyo/core/model/SharedPreferencesDataStoreTest.kt b/sdk/core/src/test/java/com/klaviyo/core/model/SharedPreferencesDataStoreTest.kt index 963a40e56..fc563684a 100644 --- a/sdk/core/src/test/java/com/klaviyo/core/model/SharedPreferencesDataStoreTest.kt +++ b/sdk/core/src/test/java/com/klaviyo/core/model/SharedPreferencesDataStoreTest.kt @@ -24,7 +24,7 @@ internal class SharedPreferencesDataStoreTest : BaseTest() { private fun withPreferenceMock() { every { - contextMock.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) + mockContext.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } returns preferenceMock } @@ -48,13 +48,13 @@ internal class SharedPreferencesDataStoreTest : BaseTest() { SharedPreferencesDataStore.store(stubKey, stubValue) - verify { contextMock.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } + verify { mockContext.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } verify { preferenceMock.edit() } verify { editorMock.putString(stubKey, stubValue) } verify { editorMock.apply() } // And verify log output - verify { logSpy.verbose("$stubKey=$stubValue") } + verify { spyLog.verbose("$stubKey=$stubValue") } } @Test @@ -65,14 +65,14 @@ internal class SharedPreferencesDataStoreTest : BaseTest() { SharedPreferencesDataStore.fetchOrCreate(stubKey) { stubValue } - verify { contextMock.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } + verify { mockContext.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } verify { preferenceMock.getString(stubKey, null) } verify { preferenceMock.edit() } verify { editorMock.putString(stubKey, stubValue) } verify { editorMock.apply() } // And verify log output for writing - verify { logSpy.verbose("$stubKey=$stubValue") } + verify { spyLog.verbose("$stubKey=$stubValue") } } @Test @@ -105,13 +105,13 @@ internal class SharedPreferencesDataStoreTest : BaseTest() { SharedPreferencesDataStore.clear(stubKey) - verify { contextMock.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } + verify { mockContext.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } verify { preferenceMock.edit() } verify { editorMock.remove(stubKey) } verify { editorMock.apply() } // And verify log output - verify { logSpy.verbose("$stubKey=null") } + verify { spyLog.verbose("$stubKey=null") } } @Test @@ -124,7 +124,7 @@ internal class SharedPreferencesDataStoreTest : BaseTest() { val actualString = SharedPreferencesDataStore.fetch(key = stubKey) assertEquals(expectedString, actualString) - verify { contextMock.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } + verify { mockContext.getSharedPreferences(KLAVIYO_PREFS_NAME, Context.MODE_PRIVATE) } verify { preferenceMock.getString(stubKey, null) } verify(inverse = true) { editorMock.apply() } } diff --git a/sdk/core/src/test/java/com/klaviyo/core/networking/KlaviyoNetworkMonitorTest.kt b/sdk/core/src/test/java/com/klaviyo/core/networking/KlaviyoNetworkMonitorTest.kt index 9d750c857..60962306a 100644 --- a/sdk/core/src/test/java/com/klaviyo/core/networking/KlaviyoNetworkMonitorTest.kt +++ b/sdk/core/src/test/java/com/klaviyo/core/networking/KlaviyoNetworkMonitorTest.kt @@ -38,8 +38,8 @@ internal class KlaviyoNetworkMonitorTest : BaseTest() { super.setup() // Mock connectivityManager for spot check and for callbacks - every { contextMock.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManagerMock - every { contextMock.getSystemService(ConnectivityManager::class.java) } returns connectivityManagerMock + every { mockContext.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManagerMock + every { mockContext.getSystemService(ConnectivityManager::class.java) } returns connectivityManagerMock every { connectivityManagerMock.activeNetwork } returns networkMock every { connectivityManagerMock.getNetworkCapabilities(null) } returns null every { connectivityManagerMock.getNetworkCapabilities(networkMock) } returns capabilitiesMock @@ -126,14 +126,13 @@ internal class KlaviyoNetworkMonitorTest : BaseTest() { expectedNetworkConnection = true every { capabilitiesMock.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true - netCallbackSlot.captured.onCapabilitiesChanged(mockk(), mockk()) netCallbackSlot.captured.onLinkPropertiesChanged(mockk(), mockk()) expectedNetworkConnection = false every { capabilitiesMock.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false netCallbackSlot.captured.onLost(mockk()) - assertEquals(6, callCount) + assertEquals(5, callCount) KlaviyoNetworkMonitor.offNetworkChange(observer) } @@ -144,11 +143,11 @@ internal class KlaviyoNetworkMonitorTest : BaseTest() { every { capabilitiesMock.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true netCallbackSlot.captured.onAvailable(mockk()) - verify { logSpy.verbose("Network available") } + verify { spyLog.verbose("Network available") } every { capabilitiesMock.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false netCallbackSlot.captured.onUnavailable() - verify { logSpy.verbose("Network unavailable") } + verify { spyLog.verbose("Network unavailable") } } @Test diff --git a/sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt b/sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt index e3fdf0d5d..5ff58b616 100644 --- a/sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt +++ b/sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt @@ -63,35 +63,42 @@ abstract class BaseTest { protected val mockApplicationInfo = mockk() protected val mockPackageManager = mockk() - protected val contextMock = mockk().apply { + protected val mockContext = mockk().apply { every { applicationInfo } returns mockApplicationInfo every { packageManager } returns mockPackageManager } - protected val configMock = mockk().apply { + protected val debounceTime = 5 + protected val mockConfig = mockk().apply { every { apiKey } returns API_KEY - every { applicationContext } returns contextMock + every { applicationContext } returns mockContext + every { debounceInterval } returns debounceTime every { networkMaxAttempts } returns 50 every { networkMaxRetryInterval } returns 180_000L every { networkFlushIntervals } returns longArrayOf(10_000, 30_000, 60_000) every { networkJitterRange } returns 0..0 every { baseUrl } returns "https://test.fake-klaviyo.com" + every { apiRevision } returns "1234-56-78" } - protected val lifecycleMonitorMock = mockk() - protected val networkMonitorMock = mockk() - protected val dataStoreSpy = spyk(InMemoryDataStore()) - protected val logSpy = spyk(LogFixture()) + protected val mockLifecycleMonitor = mockk().apply { + every { onActivityEvent(any()) } returns Unit + every { offActivityEvent(any()) } returns Unit + } + protected val mockNetworkMonitor = mockk() + protected val spyDataStore = spyk(InMemoryDataStore()) + protected val spyLog = spyk(LogFixture()) + protected val staticClock = StaticClock(TIME, ISO_TIME) @Before open fun setup() { // Mock Registry by default to encourage unit tests to be decoupled from other services mockkObject(Registry) - every { Registry.config } returns configMock - every { Registry.lifecycleMonitor } returns lifecycleMonitorMock - every { Registry.networkMonitor } returns networkMonitorMock - every { Registry.dataStore } returns dataStoreSpy - every { Registry.clock } returns StaticClock(TIME, ISO_TIME) - every { Registry.log } returns logSpy + every { Registry.config } returns mockConfig + every { Registry.lifecycleMonitor } returns mockLifecycleMonitor + every { Registry.networkMonitor } returns mockNetworkMonitor + every { Registry.dataStore } returns spyDataStore + every { Registry.clock } returns staticClock + every { Registry.log } returns spyLog // Mock using latest SDK setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), 33) diff --git a/sdk/push-fcm/build.gradle b/sdk/push-fcm/build.gradle index 4b6cd5144..d018b6278 100644 --- a/sdk/push-fcm/build.gradle +++ b/sdk/push-fcm/build.gradle @@ -1,4 +1,3 @@ -import static de.fayard.refreshVersions.core.Versions.versionFor project.description = "Push functionality for the Klaviyo SDK suite" evaluationDependsOn(":sdk") @@ -32,7 +31,7 @@ afterEvaluate { from components[ext.publishBuildVariant] groupId = klaviyoGroupId artifactId = "push-fcm" - version = versionFor(project, "version.klaviyo.versionName") + version = readXmlValue('src/main/res/values/strings.xml','klaviyo_sdk_version_override', project(":sdk:core")) } } } diff --git a/sdk/push-fcm/consumer-rules.pro b/sdk/push-fcm/consumer-rules.pro new file mode 100644 index 000000000..df4ef7a3c --- /dev/null +++ b/sdk/push-fcm/consumer-rules.pro @@ -0,0 +1,2 @@ +-keep class com.klaviyo.core.** { *; } +-keep class com.klaviyo.analytics.** { *; } \ No newline at end of file diff --git a/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoNotification.kt b/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoNotification.kt index 7c2b5001a..527ae5b78 100644 --- a/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoNotification.kt +++ b/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoNotification.kt @@ -29,6 +29,7 @@ import com.klaviyo.pushFcm.KlaviyoRemoteMessage.imageUrl import com.klaviyo.pushFcm.KlaviyoRemoteMessage.isKlaviyoNotification import com.klaviyo.pushFcm.KlaviyoRemoteMessage.notificationCount import com.klaviyo.pushFcm.KlaviyoRemoteMessage.notificationPriority +import com.klaviyo.pushFcm.KlaviyoRemoteMessage.notificationTag import com.klaviyo.pushFcm.KlaviyoRemoteMessage.sound import com.klaviyo.pushFcm.KlaviyoRemoteMessage.title import java.net.URL @@ -62,12 +63,13 @@ class KlaviyoNotification(private val message: RemoteMessage) { internal const val COLOR_KEY = "color" internal const val NOTIFICATION_COUNT_KEY = "notification_count" internal const val NOTIFICATION_PRIORITY = "notification_priority" + internal const val NOTIFICATION_TAG = "notification_tag" private const val DOWNLOAD_TIMEOUT_MS = 5_000 /** * Get an integer ID to associate with a notification or its pending intent - * The notification system service will de-dupe on this ID alone, - * and I don't think we want our notifications to be de-duped + * The notification system service will de-dupe on this if we get a null + * notification tag from the payload * * NOTE: The FCM SDK also uses a timestamp to construct its integer IDs */ @@ -104,7 +106,7 @@ class KlaviyoNotification(private val message: RemoteMessage) { NotificationManagerCompat .from(context) - .notify(generateId(), notification.build()) + .notify(message.notificationTag ?: generateId().toString(), 0, notification.build()) return true } diff --git a/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoRemoteMessage.kt b/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoRemoteMessage.kt index 53a2f5af1..fac245354 100644 --- a/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoRemoteMessage.kt +++ b/sdk/push-fcm/src/main/java/com/klaviyo/pushFcm/KlaviyoRemoteMessage.kt @@ -74,6 +74,11 @@ object KlaviyoRemoteMessage { get() = this.data[KlaviyoNotification.NOTIFICATION_PRIORITY]?.toInt() ?: NotificationCompat.PRIORITY_DEFAULT + /** + * Parse out notification tag, used as a de-duping mechanism + */ + val RemoteMessage.notificationTag: String? get() = this.data[KlaviyoNotification.NOTIFICATION_TAG] + /** * Determine if the message originated from Klaviyo from the tracking params */ diff --git a/versions.properties b/versions.properties index 576eddd2d..310c07642 100644 --- a/versions.properties +++ b/versions.properties @@ -11,9 +11,7 @@ # Project versioning, run the following gradle command to update version numbers automatically: # ./gradlew bumpVersion --nextVersion=X.Y.Z -version.klaviyo.versionCode=19 - -version.klaviyo.versionName=2.4.1 +version.klaviyo.versionCode=20 # Android versioning @@ -27,6 +25,8 @@ version.android.buildTools=34.0.0 # Project dependencies +version.kotlinx.coroutines=1.9.0 + version.org.jetbrains.dokka..versioning-plugin=1.9.10 version.firebase-bom=32.7.0