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 248a796b9..1a81b3e11 100644 --- a/sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt @@ -7,11 +7,13 @@ 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.Registry import com.klaviyo.core.config.Config import com.klaviyo.core.config.LifecycleException @@ -35,10 +37,8 @@ object Klaviyo { ) val lifecycleCallbacks: ActivityLifecycleCallbacks get() = NoOpLifecycleCallbacks - private val profileOperationQueue = ProfileOperationQueue() - init { - // Since analytics platform owns ApiClient, we must register the service on initialize + // Since analytics module owns ApiClient, we must register the service on initialize if (!Registry.isRegistered()) Registry.register { KlaviyoApiClient } } @@ -63,7 +63,8 @@ object Klaviyo { registerActivityLifecycleCallbacks(Registry.lifecycleCallbacks) } ?: throw LifecycleException() - UserInfo.startObservers() + Registry.register(KlaviyoState()) + Registry.register(StateSideEffects()) } /** @@ -79,35 +80,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) } /** @@ -131,7 +104,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 @@ -158,9 +131,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 @@ -188,9 +159,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 @@ -202,18 +171,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 @@ -229,32 +192,7 @@ object Klaviyo { * @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))) - } + Registry.get().setAttribute(propertyKey, value) } /** @@ -266,13 +204,7 @@ object Klaviyo { * 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 @@ -281,8 +213,7 @@ object Klaviyo { * @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()) } /** @@ -327,6 +258,7 @@ object Klaviyo { /** * 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..b90cad76f 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 @@ -59,9 +59,4 @@ sealed class ProfileKey(name: String) : Keyword(name) { } } -internal val PROFILE_IDENTIFIERS = arrayOf( - ProfileKey.EXTERNAL_ID, - ProfileKey.EMAIL, - ProfileKey.PHONE_NUMBER, - ProfileKey.ANONYMOUS_ID -) +internal object PROFILE_ATTRIBUTES : Keyword("attributes") 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 695d71d6d..a02926f28 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 @@ -83,14 +83,17 @@ internal object KlaviyoApiClient : ApiClient { } override fun enqueueProfile(profile: Profile) { + Registry.log.verbose("Enqueuing Profile request") enqueueRequest(ProfileApiRequest(profile)) } override fun enqueuePushToken(token: String, profile: Profile) { + Registry.log.verbose("Enqueuing Push Token request") enqueueRequest(PushTokenApiRequest(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/state/KlaviyoState.kt b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt new file mode 100644 index 000000000..94d87a508 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt @@ -0,0 +1,144 @@ +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.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.util.UUID + +/** + * Stores information on the currently active user + */ +internal class KlaviyoState : State { + + 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) + + // TODO use a better representation of push state to decouple from PushTokenApiRequest + pushState = value?.let { PushTokenApiRequest(it, getAsProfile()).requestBody } ?: "" + } + get() = _pushToken.getValue(this, ::pushToken) + + /** + * List of registered state change observers + */ + private var stateObservers = 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: String) = when (key) { + EMAIL -> email = value + EXTERNAL_ID -> externalId = value + PHONE_NUMBER -> phoneNumber = value + else -> this.attributes = (this.attributes?.copy() ?: Profile()).setProperty(key, value) + } + + /** + * 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() { + _externalId.reset() + _email.reset() + _phoneNumber.reset() + _anonymousId.reset() + _attributes.reset() + _pushToken.reset() + _pushState.reset() + + broadcastChange() + Registry.log.verbose("Reset internal user state") + } + + /** + * Clear user's attributes from internal state, leaving profile identifiers intact + */ + override fun resetAttributes() = _attributes.reset().also { + broadcastChange(PROFILE_ATTRIBUTES) + } + + private fun broadcastChange(property: PersistentObservableProperty?) = + broadcastChange(property?.key) + + private fun broadcastChange(key: Keyword? = null) = + stateObservers.forEach { it(key) } +} 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..690047794 --- /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..0f024122d --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/PersistentObservableProperty.kt @@ -0,0 +1,98 @@ +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) -> 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)) { + this.value = value + onChanged(this) + } + } + + /** + * 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..21f315457 --- /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.ProfileKey +import com.klaviyo.core.Registry +import kotlin.reflect.KProperty + +internal class PersistentObservableString( + key: ProfileKey, + 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..4285351d5 --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/State.kt @@ -0,0 +1,55 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.analytics.model.Profile +import com.klaviyo.analytics.model.ProfileKey + +typealias StateObserver = (key: Keyword?) -> Unit + +interface State { + 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: String) + + /** + * 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..c0fbb89fb --- /dev/null +++ b/sdk/analytics/src/main/java/com/klaviyo/analytics/state/StateSideEffects.kt @@ -0,0 +1,85 @@ +package com.klaviyo.analytics.state + +import com.klaviyo.analytics.model.ImmutableProfile +import com.klaviyo.analytics.model.Keyword +import com.klaviyo.analytics.model.PROFILE_ATTRIBUTES +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.PushTokenApiRequest +import com.klaviyo.core.Registry +import com.klaviyo.core.config.Clock + +internal class StateSideEffects( + private val state: State = Registry.get(), + private val apiClient: ApiClient = Registry.get() +) { + /** + * 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 { key: Keyword? -> + when (key) { + 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 -> onUserStateChange() + } + } + } + + private fun onPushStateChange() { + if (!state.pushState.isNullOrEmpty()) { + state.pushToken?.let { 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") + apiClient.enqueueProfile(it.copy()) + state.resetAttributes() // Once captured in a request, we don't keep profile attributes in state/on disk + pendingProfile = null + } + + private fun afterApiRequest(request: ApiRequest) = when { + request is PushTokenApiRequest && request.status == KlaviyoApiRequest.Status.Failed -> { + state.pushState = null + } + else -> Unit + } +} 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 034aae936..d551a3dab 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoTest.kt @@ -10,10 +10,10 @@ 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.State 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.slot @@ -51,53 +51,50 @@ 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 apiClientMock: ApiClient = mockk().apply { + Registry.register(this) + every { onApiRequest(any(), any()) } returns Unit + every { enqueueProfile(capture(capturedProfile)) } returns Unit + every { enqueueEvent(any(), any()) } returns Unit + every { enqueuePushToken(any(), any()) } returns Unit + } + + private val builderMock = mockk().apply { + every { apiKey(any()) } returns this + every { applicationContext(any()) } returns this + every { build() } returns configMock + } + + private val mockApplication = mockk().apply { + every { contextMock.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.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 builderMock DevicePropertiesTest.mockDeviceProperties() - UserInfo.reset() + Klaviyo.initialize( + apiKey = API_KEY, + applicationContext = contextMock + ) } @After override fun cleanup() { - UserInfo.reset() + Registry.get().reset() super.cleanup() DevicePropertiesTest.unmockDeviceProperties() } @@ -108,24 +105,7 @@ internal class KlaviyoTest : BaseTest() { } @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 `Initialize properly creates new config service and attaches lifecycle listeners`() { val expectedListener = Registry.lifecycleCallbacks verifyAll { builderMock.apiKey(API_KEY) @@ -235,9 +215,9 @@ internal class KlaviyoTest : BaseTest() { 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) + assertEquals(EXTERNAL_ID, Registry.get().externalId) + assertEquals(EMAIL, Registry.get().email) + assertEquals(PHONE, Registry.get().phoneNumber) } @Test @@ -274,32 +254,32 @@ internal class KlaviyoTest : BaseTest() { @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() } @@ -307,7 +287,7 @@ internal class KlaviyoTest : BaseTest() { fun `Sets user email into info`() { Klaviyo.setEmail(EMAIL) - assertEquals(EMAIL, UserInfo.email) + assertEquals(EMAIL, Registry.get().email) verifyProfileDebounced() } @@ -315,7 +295,7 @@ internal class KlaviyoTest : BaseTest() { fun `Sets user phone into info`() { Klaviyo.setPhoneNumber(PHONE) - assertEquals(PHONE, UserInfo.phoneNumber) + assertEquals(PHONE, Registry.get().phoneNumber) verifyProfileDebounced() } @@ -323,6 +303,16 @@ 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) @@ -331,31 +321,31 @@ internal class KlaviyoTest : BaseTest() { @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) { apiClientMock.enqueueProfile(any()) } } @Test fun `Reset removes push token from store`() { - UserInfo.email = EMAIL + Registry.get().email = EMAIL dataStoreSpy.store("push_token", PUSH_TOKEN) Klaviyo.resetProfile() - assertEquals("", UserInfo.email) - assertEquals(null, dataStoreSpy.fetch("push_token")) + assertNull(null, Registry.get().email) + assertNull(null, dataStoreSpy.fetch("push_token")) } @Test @@ -364,9 +354,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()) 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..ec69635d5 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 @@ -42,6 +43,14 @@ class KeywordsTest { 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 554b9937b..a6770b474 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 @@ -18,7 +18,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.mockkObject @@ -43,7 +42,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() @@ -61,7 +59,6 @@ internal class KlaviyoApiClientTest : BaseTest() { postedJob = null - every { Registry.clock } returns staticClock every { configMock.networkFlushIntervals } returns longArrayOf( flushIntervalWifi, flushIntervalCell, diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/UserInfoTest.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt similarity index 65% rename from sdk/analytics/src/test/java/com/klaviyo/analytics/UserInfoTest.kt rename to sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt index e66bd3736..81fc9df65 100644 --- a/sdk/analytics/src/test/java/com/klaviyo/analytics/UserInfoTest.kt +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt @@ -1,4 +1,4 @@ -package com.klaviyo.analytics +package com.klaviyo.analytics.state import com.klaviyo.analytics.model.Profile import com.klaviyo.analytics.model.ProfileKey @@ -10,57 +10,59 @@ import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test -internal class UserInfoTest : BaseTest() { +internal class KlaviyoStateTest : BaseTest() { + + private lateinit var state: KlaviyoState @Before override fun setup() { super.setup() - UserInfo.reset() + state = KlaviyoState().apply { reset() } } @Test fun `UserInfo is convertible to Profile`() { - UserInfo.externalId = EXTERNAL_ID - UserInfo.email = EMAIL - UserInfo.phoneNumber = PHONE - assertProfileIdentifiers(UserInfo.getAsProfile()) + state.externalId = EXTERNAL_ID + state.email = EMAIL + state.phoneNumber = PHONE + assertProfileIdentifiers(state.getAsProfile()) assertUserInfoIdentifiers() } @Test - fun `create and store a new UUID if one does not exists in data store`() { - val anonId = UserInfo.anonymousId + fun `Create and store a new UUID if one does not exists in data store`() { + val anonId = state.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`() { + 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) + assertEquals(ANON_ID, state.anonymousId) } @Test - fun `only read properties from data store once`() { + 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) + state.anonymousId + assertEquals(ANON_ID, state.anonymousId) verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name) } - UserInfo.email - assertEquals(UserInfo.email, EMAIL) + state.email + assertEquals(EMAIL, state.email) verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.EMAIL.name) } - UserInfo.externalId - assertEquals(UserInfo.externalId, EXTERNAL_ID) + state.externalId + assertEquals(EXTERNAL_ID, state.externalId) verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.EXTERNAL_ID.name) } - UserInfo.phoneNumber - assertEquals(UserInfo.phoneNumber, PHONE) + state.phoneNumber + assertEquals(PHONE, state.phoneNumber) verify(exactly = 1) { dataStoreSpy.fetch(ProfileKey.PHONE_NUMBER.name) } } @@ -71,15 +73,15 @@ internal class UserInfoTest : BaseTest() { assertNull(initialAnonId) // Start tracking a new anon ID and it should be persisted - val firstAnonId = UserInfo.anonymousId + val firstAnonId = state.anonymousId assertEquals(firstAnonId, dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name)) // Reset again should nullify in data store - UserInfo.reset() + state.reset() assertNull(dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name)) // Start tracking again should generate another new anon ID - val newAnonId = UserInfo.anonymousId + val newAnonId = state.anonymousId assertNotEquals(firstAnonId, newAnonId) assertEquals(newAnonId, dataStoreSpy.fetch(ProfileKey.ANONYMOUS_ID.name)) } @@ -88,13 +90,13 @@ internal class UserInfoTest : BaseTest() { assert(profile.externalId == EXTERNAL_ID) assert(profile.email == EMAIL) assert(profile.phoneNumber == PHONE) - assert(profile.anonymousId == UserInfo.anonymousId) + assert(profile.anonymousId == state.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) + assertEquals(EXTERNAL_ID, state.externalId) + assertEquals(EMAIL, state.email) + assertEquals(PHONE, state.phoneNumber) } } 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..b66ef190b --- /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`() { + dataStoreSpy.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`() { + dataStoreSpy.store( + "attributes", + """invalid_json""".trimIndent() + ) + + val profile = KlaviyoState().getAsProfile(withAttributes = true) + assertEquals(0, profile.attributes.propertyCount()) + verify { logSpy.warning(any(), any()) } + } + + @Test + fun `Catches bad json persisted to disk`() { + dataStoreSpy.store( + "attributes", + """{]""".trimIndent() + ) + + val profile = KlaviyoState().getAsProfile(withAttributes = true) + assertEquals(0, profile.attributes.propertyCount()) + verify { logSpy.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..92fd4b351 --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/PersistentObservableStringTest.kt @@ -0,0 +1,128 @@ +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.Assert.assertTrue +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`() { + dataStoreSpy.store(KEY, "value") + var delegatedProperty by PersistentObservableString(ProfileKey.CUSTOM(KEY)) + assertEquals("value", delegatedProperty) + delegatedProperty = "new_value" + verify(exactly = 1) { dataStoreSpy.fetch(KEY) } + verify(exactly = 1) { dataStoreSpy.store(KEY, "new_value") } + assertEquals("new_value", dataStoreSpy.fetch(KEY)) + } + + @Test + fun `Uses fallback if persistent store is empty`() { + val delegatedProperty by PersistentObservableString(ProfileKey.CUSTOM(KEY)) { "fallback" } + assertEquals("fallback", delegatedProperty) + assertEquals("fallback", dataStoreSpy.fetch(KEY)) + verify(exactly = 1) { dataStoreSpy.store(KEY, "fallback") } + } + + @Test + fun `Invokes callback when value changes`() { + var invoked = false + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { invoked = true } + ) + + assertNull(delegatedProperty) + assertFalse(invoked) + + delegatedProperty = "2" + + assertEquals("2", delegatedProperty) + assertTrue(invoked) + } + + @Test + fun `Does not store or invoke callback when value is unchanged`() { + dataStoreSpy.store(KEY, "value") + var invoked = false + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { invoked = true } + ) + + delegatedProperty = "value" + + assertFalse(invoked) + verify(exactly = 1) { dataStoreSpy.store(KEY, "value") } + } + + @Test + fun `Whitespace is trimmed prior to validation`() { + dataStoreSpy.store(KEY, "value") + var invoked = false + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { invoked = true } + ) + + delegatedProperty = " value " + + assertFalse(invoked) + verify(exactly = 1) { dataStoreSpy.store(KEY, "value") } + } + + @Test + fun `Empty string or null is ignored by primary setter method`() { + dataStoreSpy.store(KEY, "value") + var invoked = false + var delegatedProperty by PersistentObservableString( + ProfileKey.CUSTOM(KEY), + onChanged = { invoked = true } + ) + + delegatedProperty = "" + delegatedProperty = null + + assertFalse(invoked) + verify(exactly = 1) { dataStoreSpy.store(KEY, "value") } + } + + @Test + fun `Resets value without invoking callback`() { + dataStoreSpy.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(dataStoreSpy.fetch(KEY)) + } +} diff --git a/sdk/analytics/src/test/java/com/klaviyo/analytics/state/SideEffectTests.kt b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/SideEffectTests.kt new file mode 100644 index 000000000..a7282a582 --- /dev/null +++ b/sdk/analytics/src/test/java/com/klaviyo/analytics/state/SideEffectTests.kt @@ -0,0 +1,193 @@ +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.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.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class SideEffectTests : 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 { + Registry.register(this) + every { onApiRequest(any(), capture(capturedApiObserver)) } 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 { pushState = captureNullable(capturedPushState) } returns Unit + every { getAsProfile(withAttributes = any()) } returns profile + every { resetAttributes() } returns Unit + } + + @Test + fun `Subscribes on init`() { + StateSideEffects(stateMock, apiClientMock) + verify { stateMock.onStateChange(any()) } + verify { apiClientMock.onApiRequest(any(), any()) } + } + + @Test + fun `Profile changes enqueue a single profile API request`() { + StateSideEffects(stateMock, apiClientMock) + + capturedStateObserver.captured(ProfileKey.EMAIL) + capturedStateObserver.captured(PROFILE_ATTRIBUTES) + capturedStateObserver.captured(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) + + staticClock.execute(debounceTime.toLong()) + + verify(exactly = 1) { apiClientMock.enqueueProfile(any()) } + } + + @Test + fun `Resetting profile enqueues 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) + + every { stateMock.getAsProfile(withAttributes = any()) } returns Profile( + properties = mapOf( + ProfileKey.ANONYMOUS_ID to "new_anon_id" + ) + ) + + capturedStateObserver.captured(null) + + verify(exactly = 1) { apiClientMock.enqueueProfile(any()) } + + staticClock.execute(debounceTime.toLong()) + + verify(exactly = 2) { apiClientMock.enqueueProfile(any()) } + } + + @Test + fun `Attributes do enqueue a profile API request`() { + StateSideEffects(stateMock, apiClientMock) + + capturedStateObserver.captured(PROFILE_ATTRIBUTES) + + 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) + 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) + 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) + 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 + } + ) + + assertNull(capturedPushState.captured) + } + + @Test + fun `Other API failures do not affect push state`() { + StateSideEffects(stateMock, apiClientMock) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + } + ) + + capturedApiObserver.captured( + mockk().apply { + every { status } returns KlaviyoApiRequest.Status.Failed + } + ) + + assertFalse(capturedPushState.isCaptured) + } +} 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 2d72efba9..05e3039b8 100644 --- a/sdk/core/src/main/java/com/klaviyo/core/Registry.kt +++ b/sdk/core/src/main/java/com/klaviyo/core/Registry.kt @@ -16,12 +16,8 @@ import kotlin.reflect.KType import kotlin.reflect.typeOf class MissingConfig : KlaviyoException("Klaviyo SDK accessed before initializing") -class MissingRegistration(type: KType) : KlaviyoException( - "No service registered for ${type::class.qualifiedName}" -) -class InvalidRegistration(type: KType) : KlaviyoException( - "Registered service does not match ${type::class.qualifiedName}" -) +class MissingRegistration(type: KType) : KlaviyoException("No service registered for $type") +class InvalidRegistration(type: KType) : KlaviyoException("Registered service does not match $type") typealias Registration = () -> Any 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..fc98d5241 100644 --- a/sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt +++ b/sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt @@ -68,9 +68,11 @@ abstract class BaseTest { every { packageManager } returns mockPackageManager } + protected val debounceTime = 5 protected val configMock = mockk().apply { every { apiKey } returns API_KEY every { applicationContext } returns contextMock + every { debounceInterval } returns debounceTime every { networkMaxAttempts } returns 50 every { networkMaxRetryInterval } returns 180_000L every { networkFlushIntervals } returns longArrayOf(10_000, 30_000, 60_000) @@ -81,6 +83,7 @@ abstract class BaseTest { protected val networkMonitorMock = mockk() protected val dataStoreSpy = spyk(InMemoryDataStore()) protected val logSpy = spyk(LogFixture()) + protected val staticClock = StaticClock(TIME, ISO_TIME) @Before open fun setup() { @@ -90,7 +93,7 @@ abstract class BaseTest { 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.clock } returns staticClock every { Registry.log } returns logSpy // Mock using latest SDK