From 9d7b57743dbf2b925fd89879f9fd8a39886a13be Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 10 Mar 2026 19:33:54 +0100 Subject: [PATCH] Add primitives, identity manager w adapter --- .../com/superwall/sdk/config/ConfigManager.kt | 3 + .../sdk/identity/IdentityEffectDeps.kt | 18 + .../superwall/sdk/identity/IdentityManager.kt | 357 +++--------- .../sdk/identity/IdentityManagerActor.kt | 351 +++++++++++ .../superwall/sdk/misc/engine/EffectRunner.kt | 78 +++ .../com/superwall/sdk/misc/engine/SdkEvent.kt | 10 + .../com/superwall/sdk/misc/engine/SdkState.kt | 34 ++ .../superwall/sdk/misc/primitives/Effects.kt | 48 ++ .../superwall/sdk/misc/primitives/Engine.kt | 112 ++++ .../com/superwall/sdk/misc/primitives/Fx.kt | 92 +++ .../superwall/sdk/misc/primitives/Reduce.kt | 7 + .../com/superwall/sdk/store/StoreManager.kt | 17 +- .../sdk/identity/IdentityManagerTest.kt | 547 +++++++++++++++++- .../IdentityManagerUserAttributesTest.kt | 57 +- 14 files changed, 1412 insertions(+), 319 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/identity/IdentityEffectDeps.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/engine/EffectRunner.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/engine/SdkEvent.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/engine/SdkState.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/Effects.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/Engine.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/Fx.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index 7b4fc3f7..e377ac13 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -21,6 +21,7 @@ import com.superwall.sdk.misc.CurrentActivityTracker import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.awaitFirstValidConfig +import com.superwall.sdk.misc.engine.SdkState import com.superwall.sdk.misc.fold import com.superwall.sdk.misc.into import com.superwall.sdk.misc.onError @@ -272,6 +273,7 @@ open class ConfigManager( } }.then { configState.update { _ -> ConfigState.Retrieved(it) } + identityManager?.invoke()?.engine?.dispatch(SdkState.Updates.ConfigReady) }.then { if (isConfigFromCache) { ioScope.launch { refreshConfiguration() } @@ -458,6 +460,7 @@ open class ConfigManager( }.then { config -> processConfig(config) configState.update { ConfigState.Retrieved(config) } + identityManager?.invoke()?.engine?.dispatch(SdkState.Updates.ConfigReady) track( InternalSuperwallEvent.ConfigRefresh( isCached = false, diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityEffectDeps.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityEffectDeps.kt new file mode 100644 index 00000000..f5604074 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityEffectDeps.kt @@ -0,0 +1,18 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.delegate.SuperwallDelegateAdapter +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.web.WebPaywallRedeemer + +internal interface IdentityEffectDeps { + val configProvider: () -> Config? + val webPaywallRedeemer: (() -> WebPaywallRedeemer)? + val testModeManager: TestModeManager? + val deviceHelper: DeviceHelper + val delegate: (() -> SuperwallDelegateAdapter)? + val completeReset: () -> Unit + val fetchAssignments: (suspend () -> Unit)? + val notifyUserChange: ((Map) -> Unit)? +} diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt index 0e5949c1..6ec7bf2c 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -2,36 +2,34 @@ package com.superwall.sdk.identity import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track -import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.config.ConfigManager -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger +import com.superwall.sdk.delegate.SuperwallDelegateAdapter import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.misc.awaitFirstValidConfig -import com.superwall.sdk.misc.launchWithTracking -import com.superwall.sdk.misc.sha256MappedToRange +import com.superwall.sdk.misc.engine.SdkState +import com.superwall.sdk.misc.engine.createEffectRunner +import com.superwall.sdk.misc.primitives.Engine import com.superwall.sdk.network.device.DeviceHelper -import com.superwall.sdk.storage.AliasId -import com.superwall.sdk.storage.AppUserId import com.superwall.sdk.storage.DidTrackFirstSeen -import com.superwall.sdk.storage.Seed import com.superwall.sdk.storage.Storage -import com.superwall.sdk.storage.UserAttributes -import com.superwall.sdk.utilities.withErrorTracking +import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.flow.map import java.util.concurrent.Executors +/** + * Facade over the Engine-based identity system. + * + * External API is identical to the old IdentityManager — all callers + * (Superwall.kt, DependencyContainer, PublicIdentity) remain unchanged. + * + * Internally, every method dispatches an [IdentityState.Updates] event to the + * engine, and every property reads from `engine.state.value.identity`. + */ class IdentityManager( private val deviceHelper: DeviceHelper, private val storage: Storage, @@ -46,297 +44,132 @@ class IdentityManager( private val track: suspend (TrackableSuperwallEvent) -> Unit = { Superwall.instance.track(it) }, + private val webPaywallRedeemer: (() -> WebPaywallRedeemer)? = null, + private val testModeManager: TestModeManager? = null, + private val delegate: (() -> SuperwallDelegateAdapter)? = null, ) { - private companion object Keys { - val appUserId = "appUserId" - val aliasId = "aliasId" + // Single-threaded dispatcher for the engine loop + private val engineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val engineScope = CoroutineScope(engineDispatcher) - val seed = "seed" - } + // Root reducer: routes SdkEvent subtypes to slice reducers - private var _appUserId: String? = storage.read(AppUserId) + // The engine — single event loop, one source of truth + internal val engine: Engine - val appUserId: String? - get() = - runBlocking(queue) { - _appUserId - } - - private var _aliasId: String = - storage.read(AliasId) ?: IdentityLogic.generateAlias() + init { + val initial = + SdkState( + identity = createInitialIdentityState(storage, deviceHelper.appInstalledAtString), + ) - val externalAccountId: String - get() = - if (configManager.options.passIdentifiersToPlayStore) { - userId - } else { - stringToSha(userId) - } + val runEffect = + createEffectRunner( + storage = storage, + track = { track(it as TrackableSuperwallEvent) }, + configProvider = { configManager.config }, + webPaywallRedeemer = webPaywallRedeemer, + testModeManager = testModeManager, + deviceHelper = deviceHelper, + delegate = delegate, + completeReset = completeReset, + fetchAssignments = { configManager.getAssignments() }, + notifyUserChange = notifyUserChange, + ) - val aliasId: String - get() = - runBlocking(queue) { - _aliasId - } + engine = + Engine( + initial = initial, + runEffect = runEffect, + scope = engineScope, + ) + } - private var _seed: Int = - storage.read(Seed) ?: IdentityLogic.generateSeed() + // ----------------------------------------------------------------------- + // State reads — no runBlocking, no locks, just read the StateFlow + // ----------------------------------------------------------------------- - val seed: Int - get() = - runBlocking(queue) { - _seed - } + private val identity get() = engine.state.value.identity - val userId: String - get() = - runBlocking(queue) { - _appUserId ?: _aliasId - } + val appUserId: String? get() = identity.appUserId - private var _userAttributes: Map = storage.read(UserAttributes) ?: emptyMap() + val aliasId: String get() = identity.aliasId - val userAttributes: Map - get() = - runBlocking(queue) { - _userAttributes.toMutableMap().apply { - // Ensure we always have user identifiers - put(Keys.appUserId, _appUserId ?: _aliasId) - put(Keys.aliasId, _aliasId) - } - } + val seed: Int get() = identity.seed - val isLoggedIn: Boolean get() = _appUserId != null + val userId: String get() = identity.userId - private val identityFlow = MutableStateFlow(false) - val hasIdentity: Flow get() = identityFlow.asStateFlow().filter { it } + val userAttributes: Map get() = identity.enrichedAttributes - private val queue = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val scope = CoroutineScope(queue) - private val identityJobs = CopyOnWriteArrayList() + val isLoggedIn: Boolean get() = identity.isLoggedIn - init { - val extraAttributes = mutableMapOf() + val externalAccountId: String + get() = + if (configManager.options.passIdentifiersToPlayStore) { + userId + } else { + stringToSha(userId) + } - val aliasId = storage.read(AliasId) - if (aliasId == null) { - storage.write(AliasId, _aliasId) - extraAttributes[Keys.aliasId] = _aliasId - } + val hasIdentity: Flow + get() = engine.state.map { it.identity.isReady }.filter { it } - val seed = storage.read(Seed) - if (seed == null) { - storage.write(Seed, _seed) - extraAttributes[Keys.seed] = _seed - } + // ----------------------------------------------------------------------- + // Actions — dispatch events instead of mutating state directly + // ----------------------------------------------------------------------- - if (extraAttributes.isNotEmpty()) { - mergeUserAttributes( - newUserAttributes = extraAttributes, - shouldTrackMerge = false, - ) - } + private fun dispatchIdentity(update: IdentityState.Updates) { + engine.dispatch(SdkState.Updates.UpdateIdentity(update)) } fun configure() { - ioScope.launchWithTracking { - val neverCalledStaticConfig = neverCalledStaticConfig() - val isFirstAppOpen = - !(storage.read(DidTrackFirstSeen) ?: false) - - if (IdentityLogic.shouldGetAssignments( - isLoggedIn, - neverCalledStaticConfig, - isFirstAppOpen, - ) - ) { - configManager.getAssignments() - } - didSetIdentity() - } + dispatchIdentity( + IdentityState.Updates.Configure( + neverCalledStaticConfig = neverCalledStaticConfig(), + isFirstAppOpen = !(storage.read(DidTrackFirstSeen) ?: false), + ), + ) } fun identify( userId: String, options: IdentityOptions? = null, ) { - scope.launch { - withErrorTracking { - IdentityLogic.sanitize(userId)?.let { sanitizedUserId -> - if (_appUserId == sanitizedUserId || sanitizedUserId == "") { - if (sanitizedUserId == "") { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.identityManager, - message = "The provided userId was empty.", - ) - } - return@withErrorTracking - } - - identityFlow.emit(false) - - val oldUserId = _appUserId - if (oldUserId != null && sanitizedUserId != oldUserId) { - completeReset() - } - - _appUserId = sanitizedUserId - - // If we haven't gotten config yet, we need - // to leave this open to grab the appUserId for headers - identityJobs += - ioScope.launch { - val config = configManager.configState.awaitFirstValidConfig() - - if (config?.featureFlags?.enableUserIdSeed == true) { - sanitizedUserId.sha256MappedToRange()?.let { seed -> - _seed = seed - saveIds() - } - } - } - - saveIds() - - ioScope.launch { - val trackableEvent = InternalSuperwallEvent.IdentityAlias() - track(trackableEvent) - } - - configManager.checkForWebEntitlements() - configManager.reevaluateTestMode( - appUserId = _appUserId, - aliasId = _aliasId, - ) - - if (options?.restorePaywallAssignments == true) { - identityJobs += - ioScope.launch { - configManager.getAssignments() - didSetIdentity() - } - } else { - ioScope.launch { - configManager.getAssignments() - } - didSetIdentity() - } - } - } - } - } - - private fun didSetIdentity() { - scope.launch { - identityJobs.forEach { it.join() } - identityFlow.emit(true) - } - } - - /** - * Saves the `aliasId`, `seed` and `appUserId` to storage and user attributes. - */ - private fun saveIds() { - withErrorTracking { - // This is not wrapped in a scope/mutex because is - // called from the didSet of vars, who are already - // being set within the queue. - _appUserId?.let { - storage.write(AppUserId, it) - } ?: kotlin.run { storage.delete(AppUserId) } - storage.write(AliasId, _aliasId) - storage.write(Seed, _seed) - - val newUserAttributes = - mutableMapOf( - Keys.aliasId to _aliasId, - Keys.seed to _seed, - ) - _appUserId?.let { newUserAttributes[Keys.appUserId] = it } - - _mergeUserAttributes( - newUserAttributes = newUserAttributes, - ) - } + dispatchIdentity(IdentityState.Updates.Identify(userId, options)) } fun reset(duringIdentify: Boolean) { - ioScope.launch { - identityFlow.emit(false) - } - if (duringIdentify) { - _reset() + // No-op: when called from Superwall.reset(duringIdentify=true) during + // an identify flow, the Identify reducer already handles identity reset + // inline. The completeReset callback only resets OTHER managers. } else { - _reset() - didSetIdentity() + dispatchIdentity(IdentityState.Updates.Reset) } } - @Suppress("ktlint:standard:function-naming") - private fun _reset() { - _appUserId = null - _aliasId = IdentityLogic.generateAlias() - _seed = IdentityLogic.generateSeed() - _userAttributes = emptyMap() - saveIds() - } - fun mergeUserAttributes( newUserAttributes: Map, shouldTrackMerge: Boolean = true, ) { - scope.launch { - _mergeUserAttributes( - newUserAttributes = newUserAttributes, + dispatchIdentity( + IdentityState.Updates.AttributesMerged( + attrs = newUserAttributes, shouldTrackMerge = shouldTrackMerge, - ) - } + ), + ) } internal fun mergeAndNotify( newUserAttributes: Map, shouldTrackMerge: Boolean = true, ) { - scope.launch { - _mergeUserAttributes( - newUserAttributes = newUserAttributes, + dispatchIdentity( + IdentityState.Updates.AttributesMerged( + attrs = newUserAttributes, shouldTrackMerge = shouldTrackMerge, shouldNotify = true, - ) - } - } - - @Suppress("ktlint:standard:function-naming") - private fun _mergeUserAttributes( - newUserAttributes: Map, - shouldTrackMerge: Boolean = true, - shouldNotify: Boolean = false, - ) { - withErrorTracking { - val mergedAttributes = - IdentityLogic.mergeAttributes( - newAttributes = newUserAttributes, - oldAttributes = _userAttributes, - appInstalledAtString = deviceHelper.appInstalledAtString, - ) - - if (shouldTrackMerge) { - ioScope.launch { - val trackableEvent = - InternalSuperwallEvent.Attributes( - deviceHelper.appInstalledAtString, - HashMap(mergedAttributes), - ) - track(trackableEvent) - } - } - storage.write(UserAttributes, mergedAttributes) - _userAttributes = mergedAttributes - if (shouldNotify) { - notifyUserChange(mergedAttributes) - } - } + ), + ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt new file mode 100644 index 00000000..db985998 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt @@ -0,0 +1,351 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.misc.engine.SdkEvent +import com.superwall.sdk.misc.engine.SdkState +import com.superwall.sdk.misc.primitives.Effect +import com.superwall.sdk.misc.primitives.Fx +import com.superwall.sdk.misc.primitives.Reducer +import com.superwall.sdk.misc.sha256MappedToRange +import com.superwall.sdk.storage.AliasId +import com.superwall.sdk.storage.AppUserId +import com.superwall.sdk.storage.Seed +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.UserAttributes +import com.superwall.sdk.web.WebPaywallRedeemer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal object Keys { + const val APP_USER_ID = "appUserId" + const val ALIAS_ID = "aliasId" + const val SEED = "seed" +} + +enum class Pending { Seed, Assignments } + +data class IdentityState( + val appUserId: String? = null, + val aliasId: String = IdentityLogic.generateAlias(), + val seed: Int = IdentityLogic.generateSeed(), + val userAttributes: Map = emptyMap(), + val pending: Set = emptySet(), + val isReady: Boolean = false, + val appInstalledAtString: String = "", +) { + val userId: String get() = appUserId ?: aliasId + + val isLoggedIn: Boolean get() = appUserId != null + + val enrichedAttributes: Map + get() = + userAttributes.toMutableMap().apply { + put(Keys.APP_USER_ID, userId) + put(Keys.ALIAS_ID, aliasId) + } + + fun resolve(item: Pending): IdentityState { + val next = pending - item + return if (next.isEmpty()) copy(pending = next, isReady = true) else copy(pending = next) + } + + // Only functions that can update state + internal sealed class Updates( + override val applyOn: Fx.(IdentityState) -> IdentityState, + ) : Reducer(applyOn) { + data class Identify( + val userId: String, + val options: IdentityOptions?, + ) : Updates({ state -> + IdentityLogic.sanitize(userId).takeIf { !it.isNullOrEmpty() }?.let { sanitized -> + if (sanitized.isEmpty()) { + return@let state + } + if (sanitized == state.appUserId) return@let state + + val base = + if (state.appUserId != null) { + dispatch(SdkState.Updates.FullResetOnIdentify) + effect { IdentityEffect.CompleteReset } + IdentityState(appInstalledAtString = state.appInstalledAtString) + } else { + state + } + + persist(AppUserId, sanitized) + persist(AliasId, base.aliasId) + persist(Seed, base.seed) + + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.APP_USER_ID to sanitized, + Keys.ALIAS_ID to base.aliasId, + Keys.SEED to base.seed, + ), + oldAttributes = base.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + persist(UserAttributes, merged) + + track(InternalSuperwallEvent.IdentityAlias()) + + defer(until = { it.configReady }) { + effect { IdentityEffect.ResolveSeed(sanitized) } + effect { IdentityEffect.FetchAssignments } + effect { IdentityEffect.ReevaluateTestMode(sanitized, base.aliasId) } + } + + effect { IdentityEffect.CheckWebEntitlements } + + val waitForAssignments = options?.restorePaywallAssignments == true + + base.copy( + appUserId = sanitized, + userAttributes = merged, + pending = + buildSet { + add(Pending.Seed) + if (waitForAssignments) add(Pending.Assignments) + }, + isReady = false, + ) + } ?: run { + log( + logLevel = LogLevel.error, + scope = LogScope.identityManager, + message = "The provided userId was null or empty.", + ) + state + } + }) + + data class SeedResolved( + val seed: Int, + ) : Updates({ state -> + persist(Seed, seed) + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.APP_USER_ID to state.userId, + Keys.ALIAS_ID to state.aliasId, + Keys.SEED to seed, + ), + oldAttributes = state.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + persist(UserAttributes, merged) + + state + .copy( + seed = seed, + userAttributes = merged, + ).resolve(Pending.Seed) + }) + + /** Dispatched by ResolveSeed runner when enableUserIdSeed is false or sha256 returns null */ + object SeedSkipped : Updates({ state -> + state.resolve(Pending.Seed) + }) + + data class AttributesMerged( + val attrs: Map, + val shouldTrackMerge: Boolean = true, + val shouldNotify: Boolean = false, + ) : Updates({ state -> + val merged = + IdentityLogic.mergeAttributes( + newAttributes = attrs, + oldAttributes = state.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + persist(UserAttributes, merged) + if (shouldTrackMerge) { + track( + InternalSuperwallEvent.Attributes( + appInstalledAtString = state.appInstalledAtString, + audienceFilterParams = HashMap(merged), + ), + ) + } + if (shouldNotify) { + effect { IdentityEffect.NotifyUserChange(merged) } + } + state.copy(userAttributes = merged) + }) + + /** Dispatched by FetchAssignments runner on completion (success or failure) */ + object AssignmentsCompleted : Updates({ state -> + state.resolve(Pending.Assignments) + }) + + /** Replaces IdentityManager.configure() — checks whether to fetch assignments at startup */ + data class Configure( + val neverCalledStaticConfig: Boolean, + val isFirstAppOpen: Boolean, + ) : Updates({ state -> + val needsAssignments = + IdentityLogic.shouldGetAssignments( + isLoggedIn = state.isLoggedIn, + neverCalledStaticConfig = neverCalledStaticConfig, + isFirstAppOpen = isFirstAppOpen, + ) + if (needsAssignments) { + defer(until = { it.configReady }) { + effect { IdentityEffect.FetchAssignments } + } + state.copy(pending = state.pending + Pending.Assignments) + } else { + state.copy(isReady = true) + } + }) + + object Ready : Updates({ state -> + state.copy(isReady = true) + }) + + /** Public reset (Superwall.reset without duringIdentify). Identity-during-identify is a no-op at the facade. */ + object Reset : Updates({ state -> + val fresh = IdentityState(appInstalledAtString = state.appInstalledAtString) + persist(AliasId, fresh.aliasId) + persist(Seed, fresh.seed) + delete(AppUserId) + delete(UserAttributes) + + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.ALIAS_ID to fresh.aliasId, + Keys.SEED to fresh.seed, + ), + oldAttributes = emptyMap(), + appInstalledAtString = state.appInstalledAtString, + ) + persist(UserAttributes, merged) + + fresh.copy(userAttributes = merged, isReady = true) + }) + } +} + +/** + * Builds initial IdentityState from storage BEFORE the engine starts. + * This is synchronous — same as the current IdentityManager constructor. + */ +internal fun createInitialIdentityState( + storage: Storage, + appInstalledAtString: String, +): IdentityState { + val storedAliasId = storage.read(AliasId) + val storedSeed = storage.read(Seed) + + val aliasId = + storedAliasId ?: IdentityLogic.generateAlias().also { + storage.write(AliasId, it) + } + val seed = + storedSeed ?: IdentityLogic.generateSeed().also { + storage.write(Seed, it) + } + val appUserId = storage.read(AppUserId) + val userAttributes = storage.read(UserAttributes) ?: emptyMap() + + // Only merge identity keys into attributes when values were just generated. + // If both aliasId and seed came from storage, attributes are already up to date. + val needsMerge = storedAliasId == null || storedSeed == null + val finalAttributes = + if (needsMerge) { + val enriched = + IdentityLogic.mergeAttributes( + newAttributes = + buildMap { + put(Keys.ALIAS_ID, aliasId) + put(Keys.SEED, seed) + appUserId?.let { put(Keys.APP_USER_ID, it) } + }, + oldAttributes = userAttributes, + appInstalledAtString = appInstalledAtString, + ) + if (enriched != userAttributes) { + storage.write(UserAttributes, enriched) + } + enriched + } else { + userAttributes + } + + return IdentityState( + appUserId = appUserId, + aliasId = aliasId, + seed = seed, + userAttributes = finalAttributes, + isReady = false, + appInstalledAtString = appInstalledAtString, + ) +} + +internal sealed class IdentityEffect( + val execute: suspend IdentityEffectDeps.(dispatch: (SdkEvent) -> Unit) -> Unit, +) : Effect { + data class ResolveSeed( + val userId: String, + ) : IdentityEffect({ dispatch -> + val config = configProvider() + if (config?.featureFlags?.enableUserIdSeed == true) { + userId.sha256MappedToRange()?.let { + dispatch(SdkState.Updates.UpdateIdentity(IdentityState.Updates.SeedResolved(it))) + } ?: dispatch(SdkState.Updates.UpdateIdentity(IdentityState.Updates.SeedSkipped)) + } else { + dispatch(SdkState.Updates.UpdateIdentity(IdentityState.Updates.SeedSkipped)) + } + }) + + object FetchAssignments : IdentityEffect({ dispatch -> + try { + fetchAssignments?.invoke() + } finally { + dispatch(SdkState.Updates.UpdateIdentity(IdentityState.Updates.AssignmentsCompleted)) + } + }) + + object CheckWebEntitlements : IdentityEffect({ dispatch -> + webPaywallRedeemer?.invoke()?.redeem(WebPaywallRedeemer.RedeemType.Existing) + }) + + data class ReevaluateTestMode( + val appUserId: String?, + val aliasId: String, + ) : IdentityEffect({ dispatch -> + configProvider()?.let { + testModeManager?.evaluateTestMode( + config = it, + bundleId = deviceHelper.bundleId, + appUserId = appUserId, + aliasId = aliasId, + ) + } + }) + + data class NotifyUserChange( + val attributes: Map, + ) : IdentityEffect( + { dispatch -> + + notifyUserChange?.invoke(attributes) + ?: delegate?.let { + withContext(Dispatchers.Main) { + it().userAttributesDidChange(attributes) + } + } + }, + ) + + object CompleteReset : IdentityEffect({ dispatch -> + completeReset() + }) +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/engine/EffectRunner.kt b/superwall/src/main/java/com/superwall/sdk/misc/engine/EffectRunner.kt new file mode 100644 index 00000000..4aaad1d5 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/engine/EffectRunner.kt @@ -0,0 +1,78 @@ +package com.superwall.sdk.misc.engine + +import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.delegate.SuperwallDelegateAdapter +import com.superwall.sdk.identity.IdentityEffect +import com.superwall.sdk.identity.IdentityEffectDeps +import com.superwall.sdk.misc.primitives.Effect +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.storage.Storable +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.web.WebPaywallRedeemer + +/** + * Creates the top-level effect runner that the [Engine] calls for every effect. + * + * Two layers: + * 1. **Shared effects** — Persist, Delete, Track. Handled identically for every domain. + * (Dispatch and Deferred are handled by the Engine directly — they never reach here.) + * 2. **Domain effects** — self-executing via [IdentityEffectDeps] scope. + * + * Error tracking is NOT done here — the Engine wraps every launch in `withErrorTracking`. + */ +internal fun createEffectRunner( + storage: Storage, + track: suspend (Trackable) -> Unit, + configProvider: () -> Config?, + webPaywallRedeemer: (() -> WebPaywallRedeemer)?, + testModeManager: TestModeManager?, + deviceHelper: DeviceHelper, + delegate: (() -> SuperwallDelegateAdapter)?, + completeReset: () -> Unit = {}, + fetchAssignments: (suspend () -> Unit)? = null, + notifyUserChange: ((Map) -> Unit)? = null, +): suspend (Effect, (SdkEvent) -> Unit) -> Unit { + val identityDeps = + object : IdentityEffectDeps { + override val configProvider = configProvider + override val webPaywallRedeemer = webPaywallRedeemer + override val testModeManager = testModeManager + override val deviceHelper = deviceHelper + override val delegate = delegate + override val completeReset = completeReset + override val fetchAssignments = fetchAssignments + override val notifyUserChange = notifyUserChange + } + + return { effect, dispatch -> + when (effect) { + is Effect.Persist -> writeAny(storage, effect.storable, effect.value) + is Effect.Delete -> deleteAny(storage, effect.storable) + is Effect.Track -> track(effect.event) + is IdentityEffect -> effect.execute(identityDeps, dispatch) + } + } +} + +// --------------------------------------------------------------------------- +// Helpers for type-erased storage operations +// --------------------------------------------------------------------------- + +@Suppress("UNCHECKED_CAST") +private fun writeAny( + storage: Storage, + storable: Storable<*>, + value: Any, +) { + (storable as Storable).let { storage.write(it, value) } +} + +@Suppress("UNCHECKED_CAST") +private fun deleteAny( + storage: Storage, + storable: Storable<*>, +) { + (storable as Storable).let { storage.delete(it) } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/engine/SdkEvent.kt b/superwall/src/main/java/com/superwall/sdk/misc/engine/SdkEvent.kt new file mode 100644 index 00000000..ffb76030 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/engine/SdkEvent.kt @@ -0,0 +1,10 @@ +package com.superwall.sdk.misc.engine + +/** + * Marker interface for all events processed by the [com.superwall.sdk.misc.primitives.Engine]. + * + * Domain events (e.g. [com.superwall.sdk.identity.IdentityState.Updates]) implement this directly + * via [com.superwall.sdk.misc.primitives.Reducer]. Cross-cutting events like [com.superwall.sdk.misc.engine.SdkState.Updates.FullResetOnIdentify] and + * [com.superwall.sdk.misc.engine.SdkState.Updates.ConfigReady] are top-level objects in their respective domain files. + */ +interface SdkEvent diff --git a/superwall/src/main/java/com/superwall/sdk/misc/engine/SdkState.kt b/superwall/src/main/java/com/superwall/sdk/misc/engine/SdkState.kt new file mode 100644 index 00000000..7827a289 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/engine/SdkState.kt @@ -0,0 +1,34 @@ +package com.superwall.sdk.misc.engine + +import com.superwall.sdk.identity.IdentityState +import com.superwall.sdk.misc.primitives.Fx +import com.superwall.sdk.misc.primitives.Reducer + +data class SdkState( + val identity: IdentityState = IdentityState(), + val configReady: Boolean = false, +) { + companion object { + fun initial() = SdkState() + } + + internal sealed class Updates( + override val applyOn: Fx.(SdkState) -> SdkState, + ) : Reducer(applyOn) { + data class UpdateIdentity( + val update: IdentityState.Updates, + ) : Updates({ + it.copy(identity = update.applyOn(this, it.identity)) + }) + + /** Cross-cutting: resets config + entitlements + session (NOT identity — handled inline) */ + internal object FullResetOnIdentify : Updates({ + it.copy(configReady = false) + }) + + /** Dispatched by ConfigManager when config is first retrieved (or refreshed after reset). */ + internal object ConfigReady : Updates({ + it.copy(configReady = true) + }) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/Effects.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Effects.kt new file mode 100644 index 00000000..e896366f --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Effects.kt @@ -0,0 +1,48 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.misc.engine.SdkEvent +import com.superwall.sdk.misc.engine.SdkState +import com.superwall.sdk.storage.Storable + +interface Effect { + data class Persist( + val storable: Storable<*>, + val value: Any, + ) : Effect + + data class Delete( + val storable: Storable<*>, + ) : Effect + + data class Track( + val event: Trackable, + ) : Effect + + data class Dispatch( + val event: SdkEvent, + ) : Effect + + data class Log( + val logLevel: LogLevel, + val scope: LogScope, + val message: String = "", + val info: Map? = null, + val error: Throwable? = null, + ) : Effect + + /** + * A batch of effects that wait for a state predicate before executing. + * The engine holds deferred batches and checks them after every state + * transition — when [until] returns true, all [effects] are launched. + * + * This avoids suspended coroutines waiting for state (e.g. "await config") + * and keeps the effect system declarative. + */ + data class Deferred( + val until: (SdkState) -> Boolean, + val effects: List, + ) : Effect +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/Engine.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Engine.kt new file mode 100644 index 00000000..d5f955b0 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Engine.kt @@ -0,0 +1,112 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.Either.* +import com.superwall.sdk.misc.engine.SdkEvent +import com.superwall.sdk.misc.engine.SdkState +import com.superwall.sdk.utilities.withErrorTracking +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +internal class Engine( + initial: SdkState, + private val runEffect: suspend (Effect, dispatch: (SdkEvent) -> Unit) -> Unit, + scope: CoroutineScope, + private val enableLogging: Boolean = false, +) { + private val events = Channel(Channel.UNLIMITED) + private val _state = MutableStateFlow(initial) + val state: StateFlow = _state.asStateFlow() + + // Effects waiting for a state predicate to become true + private val deferred = mutableListOf() + + fun dispatch(event: SdkEvent) { + events.trySend(event) + } + + init { + scope.launch { + for (event in events) { + if (enableLogging) { + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.superwallCore, + message = "Engine: incoming event ${event::class.simpleName}: $event", + ) + } + + // 1. Reduce — pure, single-threaded + val fx = Fx() + val prev = _state.value + + @Suppress("UNCHECKED_CAST") + val next = + withErrorTracking { + (event as Reducer).applyOn(fx, _state.value) + }.let { either -> + when (either) { + is Success -> either.value + is Failure -> _state.value // keep current state on error + } + } + _state.value = next + + if (enableLogging && prev !== next) { + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.superwallCore, + message = "Engine: state transition ${prev::class.simpleName} -> ${next::class.simpleName}", + ) + } + + // 2. Process effects + if (enableLogging && fx.pending.isNotEmpty()) { + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.superwallCore, + message = "Engine: dispatching ${fx.pending.size} effect(s): ${fx.pending.map { it::class.simpleName }}", + ) + } + for (effect in fx.pending) { + when (effect) { + // Dispatch is synchronous — re-enters the channel immediately + is Effect.Dispatch -> dispatch(effect.event) + // Deferred — hold until predicate matches + is Effect.Deferred -> deferred += effect + // Everything else — launch on scope's dispatcher + else -> + launch { + withErrorTracking { runEffect(effect, ::dispatch) } + } + } + } + + // 3. Check deferred batches against new state + if (deferred.isNotEmpty()) { + val ready = deferred.filter { it.until(next) } + if (ready.isNotEmpty()) { + deferred.removeAll(ready.toSet()) + for (batch in ready) { + for (effect in batch.effects) { + when (effect) { + is Effect.Dispatch -> dispatch(effect.event) + else -> + launch { + withErrorTracking { runEffect(effect, ::dispatch) } + } + } + } + } + } + } + } + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/Fx.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Fx.kt new file mode 100644 index 00000000..18ab0e81 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Fx.kt @@ -0,0 +1,92 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.engine.SdkEvent +import com.superwall.sdk.misc.engine.SdkState +import com.superwall.sdk.storage.Storable + +internal class Fx { + internal val pending = mutableListOf() + + fun persist( + storable: Storable, + value: T, + ) { + pending += Effect.Persist(storable, value) + } + + fun delete(storable: Storable<*>) { + pending += Effect.Delete(storable) + } + + fun track(event: Trackable) { + pending += Effect.Track(event) + } + + fun dispatch(event: SdkEvent) { + pending += Effect.Dispatch(event) + } + + fun log( + logLevel: LogLevel, + scope: LogScope, + message: String = "", + info: Map? = null, + error: Throwable? = null, + ) { + Logger.debug( + logLevel, + scope, + message, + info, + error, + ) + } + + fun effect(which: () -> Effect) { + pending += which() + } + + /** + * Declare effects that only run once [until] is satisfied. + * The engine holds them and checks on every state transition. + * + * Usage: + * ``` + * defer(until = { it.config.isReady }) { + * effect { ResolveSeed(userId) } + * effect { FetchAssignments } + * } + * ``` + */ + fun defer( + until: (SdkState) -> Boolean, + block: DeferScope.() -> Unit, + ) { + val scope = DeferScope() + scope.block() + pending += Effect.Deferred(until, scope.effects) + } + + class DeferScope { + internal val effects = mutableListOf() + + fun effect(which: () -> Effect) { + effects += which() + } + } + + fun fold( + either: Either, + onSuccess: Fx.(T) -> S, + onFailure: Fx.(Throwable) -> S, + ): S = + when (either) { + is Either.Success -> onSuccess(either.value) + is Either.Failure -> onFailure(either.error) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt new file mode 100644 index 00000000..05a26e65 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt @@ -0,0 +1,7 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.misc.engine.SdkEvent + +internal open class Reducer( + open val applyOn: Fx.(S) -> S, +) : SdkEvent diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt index 63fd1d10..06262319 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt @@ -22,6 +22,7 @@ import com.superwall.sdk.store.testmode.TestModeManager import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitAll import java.util.Date +import java.util.concurrent.ConcurrentHashMap class StoreManager( val purchaseController: InternalPurchaseController, @@ -35,7 +36,7 @@ class StoreManager( StoreKit { val receiptManager by lazy(receiptManagerFactory) - private var productsByFullId: MutableMap = java.util.concurrent.ConcurrentHashMap() + private var productsByFullId: ConcurrentHashMap = ConcurrentHashMap() private data class ProductProcessingResult( val fullProductIdsToLoad: Set, @@ -148,17 +149,15 @@ class StoreManager( is ProductState.Loading -> { if (id !in newDeferreds) loading.add(state.deferred) } + is ProductState.Error -> { // Error state already exists — replace atomically for retry val deferred = CompletableDeferred() - synchronized(productsByFullId) { - if (productsByFullId[id] is ProductState.Error) { - productsByFullId[id] = ProductState.Loading(deferred) - newDeferreds[id] = deferred - } else { - (productsByFullId[id] as? ProductState.Loading)?.deferred?.let { - loading.add(it) - } + if (productsByFullId.replace(id, state, ProductState.Loading(deferred))) { + newDeferreds[id] = deferred + } else { + (productsByFullId[id] as? ProductState.Loading)?.deferred?.let { + loading.add(it) } } } diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt index d4b80c48..31a886f8 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt @@ -4,11 +4,14 @@ import com.superwall.sdk.And import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.ConfigManager import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.engine.SdkState import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.config.RawFeatureFlag import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.storage.AliasId import com.superwall.sdk.storage.AppUserId @@ -16,15 +19,21 @@ import com.superwall.sdk.storage.DidTrackFirstSeen import com.superwall.sdk.storage.Seed import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.UserAttributes +import io.mockk.Runs +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -58,6 +67,8 @@ class IdentityManagerTest { every { deviceHelper.appInstalledAtString } returns "2024-01-01" every { configManager.options } returns SuperwallOptions() every { configManager.configState } returns MutableStateFlow(ConfigState.None) + coEvery { configManager.checkForWebEntitlements() } just Runs + coEvery { configManager.getAssignments() } just Runs } /** @@ -351,6 +362,7 @@ class IdentityManagerTest { When("reset is called not during identify") { manager.reset(duringIdentify = false) + Thread.sleep(100) } Then("appUserId is cleared") { @@ -368,22 +380,20 @@ class IdentityManagerTest { } @Test - fun `reset during identify does not emit identity`() = + fun `reset during identify is a no-op because Identify reducer handles it inline`() = runTest { Given("a logged in user") { val manager = createManager(this@runTest, existingAppUserId = "user-123") + val aliasBefore = manager.aliasId When("reset is called during identify") { manager.reset(duringIdentify = true) + Thread.sleep(100) } - Then("appUserId is cleared") { - assertNull(manager.appUserId) - } - - And("new alias and seed are persisted") { - verify(atLeast = 2) { storage.write(AliasId, any()) } - verify(atLeast = 2) { storage.write(Seed, any()) } + Then("state is unchanged — Identify reducer owns the reset") { + assertEquals("user-123", manager.appUserId) + assertEquals(aliasBefore, manager.aliasId) } } } @@ -405,7 +415,7 @@ class IdentityManagerTest { When("identify is called with a new userId") { manager.identify("new-user-456") // Internal queue dispatches asynchronously - Thread.sleep(200) + Thread.sleep(100) } Then("appUserId is set") { @@ -435,11 +445,10 @@ class IdentityManagerTest { // First identify manager.identify("user-123") Thread.sleep(200) - advanceUntilIdle() When("identify is called again with the same userId") { manager.identify("user-123") - Thread.sleep(200) + Thread.sleep(100) } Then("completeReset is not called") { @@ -458,7 +467,7 @@ class IdentityManagerTest { When("identify is called with an empty string") { manager.identify("") - Thread.sleep(200) + Thread.sleep(100) } Then("appUserId remains null") { @@ -484,7 +493,7 @@ class IdentityManagerTest { When("identify is called with a different userId") { manager.identify("user-B") - Thread.sleep(200) + Thread.sleep(100) } Then("completeReset is called") { @@ -516,7 +525,7 @@ class IdentityManagerTest { When("configure is called") { manager.configure() - advanceUntilIdle() + Thread.sleep(100) } Then("getAssignments is not called") { @@ -539,7 +548,7 @@ class IdentityManagerTest { When("mergeUserAttributes is called with new attributes") { manager.mergeUserAttributes(mapOf("name" to "Test User")) - Thread.sleep(200) + Thread.sleep(100) } Then("merged attributes are written to storage") { @@ -563,8 +572,7 @@ class IdentityManagerTest { mapOf("key" to "value"), shouldTrackMerge = true, ) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) } Then("an Attributes event is tracked") { @@ -586,7 +594,7 @@ class IdentityManagerTest { mapOf("key" to "value"), shouldTrackMerge = false, ) - Thread.sleep(200) + Thread.sleep(100) } Then("no event is tracked") { @@ -605,7 +613,7 @@ class IdentityManagerTest { When("mergeAndNotify is called") { manager.mergeAndNotify(mapOf("key" to "value")) - Thread.sleep(200) + Thread.sleep(100) } Then("notifyUserChange callback is invoked") { @@ -614,5 +622,506 @@ class IdentityManagerTest { } } + @Test + fun `mergeUserAttributes does not call notifyUserChange`() = + runTest { + Given("a manager") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("mergeUserAttributes is called (not mergeAndNotify)") { + manager.mergeUserAttributes(mapOf("key" to "value")) + Thread.sleep(100) + } + + Then("notifyUserChange callback is NOT invoked") { + assertTrue(notifiedChanges.isEmpty()) + } + } + } + + // endregion + + // region identify - restorePaywallAssignments + + @Test + fun `identify with restorePaywallAssignments true sets appUserId`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) + every { configManager.configState } returns configState + + val manager = createManagerWithScope(testScope) + + When("identify is called with restorePaywallAssignments = true") { + manager.identify( + "user-restore", + options = IdentityOptions(restorePaywallAssignments = true), + ) + Thread.sleep(100) + } + + Then("appUserId is set") { + assertEquals("user-restore", manager.appUserId) + } + + And("userId is persisted") { + verify { storage.write(AppUserId, "user-restore") } + } + } + } + + @Test + fun `identify with restorePaywallAssignments false sets appUserId`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) + every { configManager.configState } returns configState + + val manager = createManagerWithScope(testScope) + + When("identify is called with restorePaywallAssignments = false (default)") { + manager.identify("user-no-restore") + Thread.sleep(100) + } + + Then("appUserId is set") { + assertEquals("user-no-restore", manager.appUserId) + } + + And("userId is persisted") { + verify { storage.write(AppUserId, "user-no-restore") } + } + } + } + + // endregion + + // region identify - side effects + + @Test + fun `identify with whitespace-only userId is a no-op`() = + runTest { + Given("a fresh manager") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("identify is called with whitespace-only string") { + manager.identify(" \n\t ") + Thread.sleep(100) + } + + Then("appUserId remains null") { + assertNull(manager.appUserId) + } + + And("completeReset is not called") { + assertFalse(resetCalled) + } + } + } + + @Test + fun `identify tracks IdentityAlias event`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) + every { configManager.configState } returns configState + + val manager = createManagerWithScope(testScope) + + When("identify is called with a new userId") { + manager.identify("user-track-test") + Thread.sleep(100) + } + + Then("an IdentityAlias event is tracked") { + assertTrue( + "Expected IdentityAlias event in tracked events, got: $trackedEvents", + trackedEvents.any { it is InternalSuperwallEvent.IdentityAlias }, + ) + } + } + } + + @Test + fun `identify persists aliasId along with appUserId`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) + every { configManager.configState } returns configState + + val manager = createManagerWithScope(testScope) + + When("identify is called") { + manager.identify("user-side-effects") + Thread.sleep(100) + } + + Then("appUserId is persisted") { + verify { storage.write(AppUserId, "user-side-effects") } + } + + And("aliasId is persisted alongside it") { + verify { storage.write(AliasId, any()) } + } + + And("seed is persisted alongside it") { + verify { storage.write(Seed, any()) } + } + } + } + + // endregion + + // region identify - seed re-computation with enableUserIdSeed + + @Test + fun `identify re-seeds from userId SHA when enableUserIdSeed flag is true`() = + runTest { + Given("a config with enableUserIdSeed enabled") { + val configWithFlag = + Config.stub().copy( + rawFeatureFlags = + listOf( + RawFeatureFlag("enable_userid_seed", true), + ), + ) + val configState = MutableStateFlow(ConfigState.Retrieved(configWithFlag)) + every { configManager.configState } returns configState + + val manager = + IdentityManager( + deviceHelper = deviceHelper, + storage = storage, + configManager = configManager, + ioScope = IOScope(this@runTest.coroutineContext), + neverCalledStaticConfig = { false }, + notifyUserChange = { notifiedChanges.add(it) }, + completeReset = { resetCalled = true }, + track = { trackedEvents.add(it) }, + ) + + val seedBefore = manager.seed + + When("identify is called with a userId") { + manager.identify("deterministic-user") + Thread.sleep(100) + } + + Then("seed is updated based on the userId hash") { + val seedAfter = manager.seed + // The seed should be deterministically derived from the userId + assertTrue("Seed should be in range 0-99, got: $seedAfter", seedAfter in 0..99) + // Verify seed was written to storage + verify(atLeast = 1) { storage.write(Seed, any()) } + } + } + } + + // endregion + + // region hasIdentity flow + + @Test + fun `hasIdentity emits true after configure`() = + runTest { + Given("a fresh manager") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + neverCalledStaticConfig = false, + ) + + When("configure is called") { + manager.configure() + Thread.sleep(100) + } + + Then("hasIdentity emits true") { + val result = withTimeout(2000) { manager.hasIdentity.first() } + assertTrue(result) + } + } + } + + @Test + fun `hasIdentity emits true after configure for returning user`() = + runTest { + Given("a returning anonymous user") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + existingAliasId = "returning-alias", + neverCalledStaticConfig = false, + ) + + var identityReceived = false + val collectJob = + launch { + manager.hasIdentity.first() + identityReceived = true + } + + When("configure is called") { + manager.configure() + Thread.sleep(100) + advanceUntilIdle() + } + + Then("hasIdentity emitted true") { + collectJob.cancel() + assertTrue( + "hasIdentity should have emitted true after configure", + identityReceived, + ) + } + } + } + + // endregion + + // region configure - additional cases + + @Test + fun `configure calls getAssignments when logged in and neverCalledStaticConfig`() = + runTest { + Given("a logged-in returning user with neverCalledStaticConfig = true") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + existingAppUserId = "user-123", + neverCalledStaticConfig = true, + ) + + When("configure is called and config becomes ready") { + manager.configure() + Thread.sleep(100) + manager.engine.dispatch(SdkState.Updates.ConfigReady) + Thread.sleep(100) + } + + Then("getAssignments is called") { + coVerify(exactly = 1) { configManager.getAssignments() } + } + } + } + + @Test + fun `configure calls getAssignments for anonymous returning user with neverCalledStaticConfig`() = + runTest { + Given("an anonymous returning user with neverCalledStaticConfig = true") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true // not first open + + val manager = + createManagerWithScope( + ioScope = testScope, + neverCalledStaticConfig = true, + ) + + When("configure is called and config becomes ready") { + manager.configure() + Thread.sleep(100) + manager.engine.dispatch(SdkState.Updates.ConfigReady) + Thread.sleep(100) + } + + Then("getAssignments is called") { + coVerify(exactly = 1) { configManager.getAssignments() } + } + } + } + + @Test + fun `configure does not call getAssignments when neverCalledStaticConfig is false`() = + runTest { + Given("a logged-in user but static config has been called") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + existingAppUserId = "user-123", + neverCalledStaticConfig = false, + ) + + When("configure is called") { + manager.configure() + Thread.sleep(100) + } + + Then("getAssignments is not called") { + coVerify(exactly = 0) { configManager.getAssignments() } + } + } + } + + // endregion + + // region reset - custom attributes cleared + + @Test + fun `reset clears custom attributes but repopulates identity fields`() = + runTest { + Given("an identified user with custom attributes") { + val manager = + createManager( + this@runTest, + existingAppUserId = "user-123", + existingAliasId = "old-alias", + existingSeed = 42, + existingAttributes = + mapOf( + "aliasId" to "old-alias", + "seed" to 42, + "appUserId" to "user-123", + "customName" to "John", + "customEmail" to "john@test.com", + "applicationInstalledAt" to "2024-01-01", + ), + ) + + When("reset is called") { + manager.reset(duringIdentify = false) + } + + Thread.sleep(100) + + Then("custom attributes are gone") { + val attrs = manager.userAttributes + assertFalse( + "customName should not survive reset, got: $attrs", + attrs.containsKey("customName"), + ) + assertFalse( + "customEmail should not survive reset, got: $attrs", + attrs.containsKey("customEmail"), + ) + } + + And("identity fields are repopulated with new values") { + val attrs = manager.userAttributes + assertTrue(attrs.containsKey("aliasId")) + assertTrue(attrs.containsKey("seed")) + assertNotEquals("old-alias", attrs["aliasId"]) + } + } + } + + // endregion + + // region userAttributes getter invariant + + @Test + fun `userAttributes getter always injects identity fields even when internal map is empty`() = + runTest { + Given("a manager with no stored attributes") { + val manager = createManager(this@runTest, existingAliasId = "test-alias", existingSeed = 55) + + Then("userAttributes always contains aliasId") { + val attrs = manager.userAttributes + assertTrue( + "userAttributes must always contain aliasId, got: $attrs", + attrs.containsKey("aliasId"), + ) + assertEquals("test-alias", attrs["aliasId"]) + } + + And("userAttributes always contains appUserId (falls back to aliasId when anonymous)") { + val attrs = manager.userAttributes + assertTrue(attrs.containsKey("appUserId")) + assertEquals("test-alias", attrs["appUserId"]) + } + } + } + + @Test + fun `userAttributes getter reflects appUserId after identify`() = + runTest { + Given("a fresh manager") { + val testScope = IOScope(this@runTest.coroutineContext) + val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) + every { configManager.configState } returns configState + + val manager = createManagerWithScope(testScope) + val aliasBeforeIdentify = manager.aliasId + + When("identify is called") { + manager.identify("real-user") + Thread.sleep(100) + } + + Then("userAttributes appUserId reflects the identified user") { + assertEquals("real-user", manager.userAttributes["appUserId"]) + } + + And("userAttributes aliasId is still present") { + assertEquals(aliasBeforeIdentify, manager.userAttributes["aliasId"]) + } + } + } + + // endregion + + // region concurrent operations + + @Test + fun `concurrent identify and mergeUserAttributes do not lose data`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) + every { configManager.configState } returns configState + + val manager = createManagerWithScope(testScope) + + When("identify and mergeUserAttributes are called concurrently") { + val job1 = launch { manager.identify("concurrent-user") } + val job2 = + launch { + manager.mergeUserAttributes( + mapOf("name" to "Test", "plan" to "premium"), + ) + } + job1.join() + job2.join() + Thread.sleep(100) + } + + Then("appUserId is set correctly") { + assertEquals("concurrent-user", manager.appUserId) + } + + And("identity fields are always present in userAttributes") { + val attrs = manager.userAttributes + assertTrue( + "aliasId must be present, got: $attrs", + attrs.containsKey("aliasId"), + ) + assertTrue( + "appUserId must be present, got: $attrs", + attrs.containsKey("appUserId"), + ) + } + } + } + // endregion } diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt index ba6935c3..29b9128f 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt @@ -20,7 +20,6 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -122,7 +121,7 @@ class IdentityManagerUserAttributesTest { } // Allow scope.launch from init's mergeUserAttributes to complete - Thread.sleep(200) + Thread.sleep(100) Then("userAttributes contains aliasId") { val attrs = manager.userAttributes @@ -157,8 +156,8 @@ class IdentityManagerUserAttributesTest { When("identify is called with a new userId") { manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes contains appUserId") { @@ -254,8 +253,8 @@ class IdentityManagerUserAttributesTest { When("identify is called with the SAME userId") { manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes still contains aliasId") { @@ -288,7 +287,7 @@ class IdentityManagerUserAttributesTest { } // Allow any async merges to complete - Thread.sleep(200) + Thread.sleep(100) Then("aliasId individual field is correct") { assertEquals("stored-alias", manager.aliasId) @@ -338,14 +337,14 @@ class IdentityManagerUserAttributesTest { When("identify is called with the SAME userId (early return, no saveIds)") { manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } And("setUserAttributes is called with custom data") { manager.mergeUserAttributes(mapOf("name" to "John")) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes should contain the custom attribute") { @@ -388,8 +387,8 @@ class IdentityManagerUserAttributesTest { When("setUserAttributes is called without any identify") { manager.mergeUserAttributes(mapOf("name" to "John")) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes contains custom attribute") { @@ -435,7 +434,7 @@ class IdentityManagerUserAttributesTest { } // Allow async operations - Thread.sleep(200) + Thread.sleep(100) Then("userAttributes contains the NEW aliasId") { val attrs = manager.userAttributes @@ -489,8 +488,8 @@ class IdentityManagerUserAttributesTest { When("identify is called with a DIFFERENT userId (triggers reset)") { manager.identify("user-B") - Thread.sleep(300) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("appUserId is user-B") { @@ -530,8 +529,8 @@ class IdentityManagerUserAttributesTest { // First identify to get appUserId into attributes manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) val attrsBefore = manager.userAttributes assertNotNull( @@ -544,8 +543,8 @@ class IdentityManagerUserAttributesTest { manager.mergeUserAttributes( mapOf("name" to "John", "email" to "john@example.com"), ) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("custom attributes are added") { @@ -578,13 +577,13 @@ class IdentityManagerUserAttributesTest { val manager = createManagerWithScope(testScope) manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) When("setUserAttributes is called with aliasId = null") { manager.mergeUserAttributes(mapOf("aliasId" to null)) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("aliasId is removed from userAttributes") { @@ -615,8 +614,8 @@ class IdentityManagerUserAttributesTest { val manager = createManagerWithScope(testScope) manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) Then("aliasId field matches userAttributes aliasId") { assertEquals( @@ -664,7 +663,7 @@ class IdentityManagerUserAttributesTest { manager.reset(duringIdentify = false) } - Thread.sleep(200) + Thread.sleep(100) Then("aliasId field matches userAttributes aliasId") { assertEquals( @@ -707,7 +706,7 @@ class IdentityManagerUserAttributesTest { } // Allow init merge to complete - Thread.sleep(200) + Thread.sleep(100) Then("userAttributes contains the newly generated aliasId") { val attrs = manager.userAttributes @@ -740,7 +739,7 @@ class IdentityManagerUserAttributesTest { } // Allow any async operations to complete - Thread.sleep(200) + Thread.sleep(100) Then("the individual fields are correct") { assertEquals("stored-alias", manager.aliasId)