diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt index 728387e7..0cad076c 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt @@ -22,7 +22,6 @@ import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.After @@ -93,7 +92,7 @@ class PaywallViewDismissTest { @Test fun dismiss_purchased_emits_dismissed_and_clears_publisher() = - runTest { + runBlocking { var callbackShouldDismiss: Boolean? = null val finished = kotlinx.coroutines.CompletableDeferred() val callback = @@ -114,34 +113,30 @@ class PaywallViewDismissTest { val publisher = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) val request = makeRequest() Given("a paywall view configured with a dismissal callback") { - runBlocking { - withContext(Dispatchers.Main) { - view.set(request, publisher, null) - view.onViewCreated() - } + withContext(Dispatchers.Main) { + view.set(request, publisher, null) + view.onViewCreated() } When("the paywall is dismissed after a purchase due to system logic") { - runBlocking { - withContext(Dispatchers.Main) { - view.dismiss( - result = PaywallResult.Purchased(productId = "product1"), - closeReason = PaywallCloseReason.SystemLogic, - ) - } + withContext(Dispatchers.Main) { + view.dismiss( + result = PaywallResult.Purchased(productId = "product1"), + closeReason = PaywallCloseReason.SystemLogic, + ) + } - delayFor(100.milliseconds) + delayFor(100.milliseconds) - withContext(Dispatchers.Main) { - view.beforeOnDestroy() - view.destroyed() - } + withContext(Dispatchers.Main) { + view.beforeOnDestroy() + view.destroyed() + } - withContext(Dispatchers.IO) { - try { - withTimeout(3000) { finished.await() } - } catch (_: Throwable) { - } + withContext(Dispatchers.IO) { + try { + withTimeout(3000) { finished.await() } + } catch (_: Throwable) { } } @@ -168,7 +163,7 @@ class PaywallViewDismissTest { @Test fun dismiss_declined_for_next_paywall_does_not_clear_publisher() = - runTest { + runBlocking { val callback = object : PaywallViewCallback { override fun onFinished( @@ -185,23 +180,19 @@ class PaywallViewDismissTest { val publisher = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) val request = makeRequest() Given("a paywall view configured to continue to the next paywall") { - runBlocking { - withContext(Dispatchers.Main) { - view.set(request, publisher, null) - view.onViewCreated() - } + withContext(Dispatchers.Main) { + view.set(request, publisher, null) + view.onViewCreated() } When("the paywall is dismissed as declined for the next paywall") { - runBlocking { - withContext(Dispatchers.Main) { - view.dismiss( - result = PaywallResult.Declined(), - closeReason = PaywallCloseReason.ForNextPaywall, - ) - view.beforeOnDestroy() - view.destroyed() - } + withContext(Dispatchers.Main) { + view.dismiss( + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ForNextPaywall, + ) + view.beforeOnDestroy() + view.destroyed() } val dismissed = publisher.replayCache.lastOrNull() as? PaywallState.Dismissed diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt index 658cdb20..84750f8e 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt @@ -309,29 +309,24 @@ class TransactionManagerTest { Then("The purchase is successful") { assert(result is PurchaseResult.Purchased) coVerify { storeManager.loadPurchasedProducts(any()) } - And("Verify event order") { + And("Verify transaction events") { val transactionEvents = events.value.filterIsInstance() - assert( - transactionEvents.first().superwallPlacement - is SuperwallEvent.TransactionStart, - ) - assert(transactionEvents.first().product?.fullIdentifier == "product1") - assert( - transactionEvents.last().superwallPlacement - is SuperwallEvent.TransactionComplete, - ) - assert( - ( - transactionEvents.last().superwallPlacement - as SuperwallEvent.TransactionComplete - ).transaction!! - .originalTransactionIdentifier != null, - ) + val start = + transactionEvents + .single { it.superwallPlacement is SuperwallEvent.TransactionStart } + assert(start.product?.fullIdentifier == "product1") + + val complete = + transactionEvents + .mapNotNull { it.superwallPlacement as? SuperwallEvent.TransactionComplete } + .single() + assert(complete.transaction!!.originalTransactionIdentifier != null) + val purchase = events.value.filterIsInstance() - assert(purchase.first().product?.fullIdentifier == "product1") + assert(purchase.any { it.product?.fullIdentifier == "product1" }) } } } @@ -419,16 +414,18 @@ class TransactionManagerTest { Then("The purchase is successful") { assert(result is PurchaseResult.Purchased) coVerify { storeManager.loadPurchasedProducts(any()) } - And("Verify event order") { + And("Verify transaction events") { val transactionEvents = events.value.filterIsInstance() - assert(transactionEvents.first().superwallPlacement is SuperwallEvent.TransactionStart) - val complete = transactionEvents.last().superwallPlacement - assert(complete is SuperwallEvent.TransactionComplete) - assert((complete as SuperwallEvent.TransactionComplete).transaction!!.originalTransactionIdentifier != null) + assert(transactionEvents.any { it.superwallPlacement is SuperwallEvent.TransactionStart }) + val complete = + transactionEvents + .mapNotNull { it.superwallPlacement as? SuperwallEvent.TransactionComplete } + .single() + assert(complete.transaction!!.originalTransactionIdentifier != null) val purchase = events.value.filterIsInstance() - assert(purchase.first().product?.fullIdentifier == "product1") + assert(purchase.any { it.product?.fullIdentifier == "product1" }) } } } @@ -772,8 +769,8 @@ class TransactionManagerTest { val restoreEvents = events.value.filterIsInstance() assert(restoreEvents.size == 2) - assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) - assert(restoreEvents[1].state is InternalSuperwallEvent.Restore.State.Complete) + assert(restoreEvents.any{ it.state is InternalSuperwallEvent.Restore.State.Start }) + assert(restoreEvents.any{ it.state is InternalSuperwallEvent.Restore.State.Complete }) } } } @@ -802,8 +799,8 @@ class TransactionManagerTest { val restoreEvents = events.value.filterIsInstance() assert(restoreEvents.size == 2) - assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) - assert(restoreEvents[1].state is InternalSuperwallEvent.Restore.State.Complete) + assert(restoreEvents.any { it.state is InternalSuperwallEvent.Restore.State.Start }) + assert(restoreEvents.any { it.state is InternalSuperwallEvent.Restore.State.Complete }) } } } @@ -836,8 +833,8 @@ class TransactionManagerTest { val restoreEvents = events.value.filterIsInstance() assert(restoreEvents.size == 2) - assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) - assert(restoreEvents[1].state is InternalSuperwallEvent.Restore.State.Failure) + assert(restoreEvents.any { it.state is InternalSuperwallEvent.Restore.State.Start }) + assert(restoreEvents.any { it.state is InternalSuperwallEvent.Restore.State.Failure }) } } } @@ -868,9 +865,11 @@ class TransactionManagerTest { val restoreEvents = events.value.filterIsInstance() assert(restoreEvents.size == 2) - assert(restoreEvents[0].state is InternalSuperwallEvent.Restore.State.Start) - val failure: InternalSuperwallEvent.Restore.State.Failure = - restoreEvents[1].state as InternalSuperwallEvent.Restore.State.Failure + assert(restoreEvents.any { it.state is InternalSuperwallEvent.Restore.State.Start }) + val failure = + restoreEvents + .mapNotNull { it.state as? InternalSuperwallEvent.Restore.State.Failure } + .single() assert(failure.reason.contains("\"inactive\"")) } } diff --git a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt new file mode 100644 index 00000000..5e52d378 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt @@ -0,0 +1,33 @@ +package com.superwall.sdk + +import com.superwall.sdk.config.ConfigManager +import com.superwall.sdk.misc.awaitFirstValidConfig +import com.superwall.sdk.models.config.Config + +/** + * Cross-slice bridge used by the identity actor to call into other managers. + * + * Keeps the identity slice decoupled from concrete manager types. + */ +interface SdkContext { + fun reevaluateTestMode(appUserId: String?, aliasId: String?) + + suspend fun fetchAssignments() + + suspend fun awaitConfig(): Config? +} + +class SdkContextImpl( + private val configManager: () -> ConfigManager, +) : SdkContext { + override fun reevaluateTestMode(appUserId: String?, aliasId: String?) { + configManager().reevaluateTestMode(appUserId = appUserId, aliasId = aliasId) + } + + override suspend fun fetchAssignments() { + configManager().getAssignments() + } + + override suspend fun awaitConfig(): Config? = + configManager().configState.awaitFirstValidConfig() +} diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 5b2034bd..7ca44896 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -682,7 +682,9 @@ class Superwall( } // Implicitly wait dependencyContainer.configManager.fetchConfiguration() - dependencyContainer.identityManager.configure() + dependencyContainer.identityManager.configure( + neverCalledStaticConfig = dependencyContainer.storage.neverCalledStaticConfig, + ) }.toResult().fold({ CoroutineScope(Dispatchers.Main).launch { completion?.invoke(Result.success(Unit)) @@ -827,18 +829,26 @@ class Superwall( } /** - * Asynchronously resets. Presentation of paywalls is suspended until reset completes. + * Coordinates reset through the identity actor. Presentation of paywalls is + * suspended until reset completes. */ - internal fun reset(duringIdentify: Boolean) { + internal fun reset(duringIdentify: Boolean = false) { withErrorTracking { - dependencyContainer.identityManager.reset(duringIdentify) - dependencyContainer.storage.reset() - dependencyContainer.paywallManager.resetCache() - presentationItems.reset() - dependencyContainer.configManager.reset() - dependencyContainer.reedemer.clear(RedemptionOwnershipType.AppUser) - ioScope.launch { - track(InternalSuperwallEvent.Reset) + if (!duringIdentify) { + // Public reset — delegate to identity actor which coordinates + // dropping readiness, running cleanup, and restoring readiness. + dependencyContainer.identityManager.reset() + } else { + // Called from identity actor's completeReset during identify + // or full reset — just do cleanup without touching identity. + dependencyContainer.storage.reset() + dependencyContainer.paywallManager.resetCache() + presentationItems.reset() + dependencyContainer.configManager.reset() + dependencyContainer.reedemer.clear(RedemptionOwnershipType.AppUser) + ioScope.launch { + track(InternalSuperwallEvent.Reset) + } } } } diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 202150f2..34571229 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -8,6 +8,8 @@ import android.webkit.WebSettings import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModelProvider import com.android.billingclient.api.Purchase +import com.superwall.sdk.SdkContextImpl +import com.superwall.sdk.SdkContext import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.AttributionManager import com.superwall.sdk.analytics.ClassifierDataFactory @@ -34,8 +36,13 @@ import com.superwall.sdk.debug.DebugView import com.superwall.sdk.deeplinks.DeepLinkRouter import com.superwall.sdk.delegate.SuperwallDelegateAdapter import com.superwall.sdk.delegate.subscription_controller.PurchaseController +import com.superwall.sdk.identity.IdentityContext import com.superwall.sdk.identity.IdentityInfo import com.superwall.sdk.identity.IdentityManager +import com.superwall.sdk.identity.IdentityPendingInterceptor +import com.superwall.sdk.identity.IdentityPersistenceInterceptor +import com.superwall.sdk.identity.IdentityState +import com.superwall.sdk.identity.createInitialIdentityState import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -44,6 +51,8 @@ import com.superwall.sdk.misc.AppLifecycleObserver import com.superwall.sdk.misc.CurrentActivityTracker import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope +import com.superwall.sdk.misc.primitives.DebugInterceptor +import com.superwall.sdk.misc.primitives.SequentialActor import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureFlags import com.superwall.sdk.models.entitlements.SubscriptionStatus @@ -125,7 +134,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.ClassDiscriminatorMode import kotlinx.serialization.json.Json import java.lang.ref.WeakReference @@ -254,7 +262,13 @@ class DependencyContainer( this, ) storage = - LocalStorage(context = context, ioScope = ioScope(), factory = this, json = json(), _apiKey = apiKey) + LocalStorage( + context = context, + ioScope = ioScope(), + factory = this, + json = json(), + _apiKey = apiKey + ) entitlements = Entitlements(storage) testModeManager = TestModeManager(storage) testModeTransactionHandler = @@ -400,6 +414,19 @@ class DependencyContainer( }, ) + // Identity actor setup + val initialIdentity = createInitialIdentityState(storage, deviceHelper.appInstalledAtString) + val identityActor = SequentialActor(initialIdentity) + + // DebugInterceptor.install(identityActor, name = "Identity") + IdentityPendingInterceptor.install(identityActor) + IdentityPersistenceInterceptor.install(identityActor, storage) + + val sdkContext: SdkContext = + SdkContextImpl( + configManager = { configManager }, + ) + configManager = ConfigManager( context = context, @@ -427,14 +454,11 @@ class DependencyContainer( entitlements.setSubscriptionStatus(status) }, ) + identityManager = IdentityManager( storage = storage, - deviceHelper = deviceHelper, - configManager = configManager, - neverCalledStaticConfig = { - storage.neverCalledStaticConfig - }, + options = { options }, ioScope = ioScope, stringToSha = { val bytes = this.toString().toByteArray() @@ -445,6 +469,9 @@ class DependencyContainer( notifyUserChange = { delegate().userAttributesDidChange(it) }, + webPaywallRedeemer = { reedemer }, + actor = identityActor, + sdkContext = sdkContext, ) reedemer = @@ -591,9 +618,9 @@ class DependencyContainer( val paywallActivity = ( - paywallView.encapsulatingActivity?.get() - ?: activityProvider?.getCurrentActivity() - ) as? SuperwallPaywallActivity + paywallView.encapsulatingActivity?.get() + ?: activityProvider?.getCurrentActivity() + ) as? SuperwallPaywallActivity if (paywallActivity != null) { ioScope.launch { @@ -610,7 +637,12 @@ class DependencyContainer( ) } // Await message delivery to ensure webview has time to process before dismiss - paywallView.webView.messageHandler.handle(PaywallMessage.TrialStarted(trialEndDate, id)) + paywallView.webView.messageHandler.handle( + PaywallMessage.TrialStarted( + trialEndDate, + id + ) + ) } }, ) @@ -702,8 +734,8 @@ class DependencyContainer( "X-Low-Power-Mode" to deviceHelper.isLowPowerModeEnabled.toString(), "X-Is-Sandbox" to deviceHelper.isSandbox.toString(), "X-Entitlement-Status" to - Superwall.instance.entitlements.status.value - .toString(), + Superwall.instance.entitlements.status.value + .toString(), "Content-Type" to "application/json", "X-Current-Time" to dateFormat(DateUtils.ISO_MILLIS).format(Date()), "X-Static-Config-Build-Id" to (configManager.config?.buildId ?: ""), @@ -800,7 +832,8 @@ class DependencyContainer( return view } - override fun makeCache(): PaywallViewCache = PaywallViewCache(context, makeViewStore(), activityProvider!!, deviceHelper) + override fun makeCache(): PaywallViewCache = + PaywallViewCache(context, makeViewStore(), activityProvider!!, deviceHelper) override fun activePaywallId(): String? = paywallManager.currentView @@ -835,9 +868,11 @@ class DependencyContainer( audienceFilterParams = HashMap(identityManager.userAttributes), ) - override fun makeHasExternalPurchaseController(): Boolean = storeManager.purchaseController.hasExternalPurchaseController + override fun makeHasExternalPurchaseController(): Boolean = + storeManager.purchaseController.hasExternalPurchaseController - override fun makeHasInternalPurchaseController(): Boolean = storeManager.purchaseController.hasInternalPurchaseController + override fun makeHasInternalPurchaseController(): Boolean = + storeManager.purchaseController.hasInternalPurchaseController override fun isWebToAppEnabled(): Boolean = configManager.config?.featureFlags?.web2App ?: false @@ -927,7 +962,8 @@ class DependencyContainer( override fun makeFeatureFlags(): FeatureFlags? = configManager.config?.featureFlags - override fun makeComputedPropertyRequests(): List = configManager.config?.allComputedProperties ?: emptyList() + override fun makeComputedPropertyRequests(): List = + configManager.config?.allComputedProperties ?: emptyList() override suspend fun makeIdentityInfo(): IdentityInfo = IdentityInfo( @@ -965,7 +1001,8 @@ class DependencyContainer( appSessionId = appSessionManager.appSession.id, ) - override suspend fun activeProductIds(): List = storeManager.receiptManager.purchases.toList() + override suspend fun activeProductIds(): List = + storeManager.receiptManager.purchases.toList() override suspend fun makeIdentityManager(): IdentityManager = identityManager @@ -992,7 +1029,8 @@ class DependencyContainer( get() = ViewModelFactory() private val vmProvider = ViewModelProvider(storeOwner, vmFactory) - override fun makeViewStore(): ViewStorageViewModel = vmProvider[ViewStorageViewModel::class.java] + override fun makeViewStore(): ViewStorageViewModel = + vmProvider[ViewStorageViewModel::class.java] private var _mainScope: MainScope? = null private var _ioScope: IOScope? = null @@ -1056,7 +1094,8 @@ class DependencyContainer( override fun context(): Context = context - override fun experimentalProperties(): Map = storeManager.receiptManager.experimentalProperties() + override fun experimentalProperties(): Map = + storeManager.receiptManager.experimentalProperties() override fun getCurrentUserAttributes(): Map = identityManager.userAttributes @@ -1064,7 +1103,8 @@ class DependencyContainer( override fun demandScore(): Int? = deviceHelper.demandScore - override suspend fun track(event: TrackableSuperwallEvent): Result = Superwall.instance.track(event) + override suspend fun track(event: TrackableSuperwallEvent): Result = + Superwall.instance.track(event) override fun delegate(): SuperwallDelegateAdapter = delegateAdapter @@ -1098,7 +1138,8 @@ class DependencyContainer( delegateAdapter.didRedeemLink(redemptionResult) } - override fun maxAge(): Long = configManager.config?.webToAppConfig?.entitlementsMaxAgeMs ?: 86400000L + override fun maxAge(): Long = + configManager.config?.webToAppConfig?.entitlementsMaxAgeMs ?: 86400000L override fun getActiveDeviceEntitlements(): Set = entitlements.activeDeviceEntitlements @@ -1140,7 +1181,8 @@ class DependencyContainer( ?.flatMap { entitlements.byProductId(it) } ?.toSet() ?: emptySet() - override fun getPaywallInfo(): PaywallInfo = Superwall.instance.paywallView?.info ?: PaywallInfo.empty() + override fun getPaywallInfo(): PaywallInfo = + Superwall.instance.paywallView?.info ?: PaywallInfo.empty() override fun trackRestorationFailed(message: String) { trackRestorationFailure(message) diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt new file mode 100644 index 00000000..5f0fa7a3 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt @@ -0,0 +1,20 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.SdkContext +import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.misc.primitives.BaseContext +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.web.WebPaywallRedeemer + +/** + * All dependencies available to identity [IdentityState.Actions]. + * + * Cross-slice config/test-mode/assignments dispatch goes through [sdkContext]. + */ +interface IdentityContext : BaseContext { + val sdkContext: SdkContext + val webPaywallRedeemer: () -> WebPaywallRedeemer + val completeReset: () -> Unit + val track: suspend (Trackable) -> Unit + val notifyUserChange: ((Map) -> Unit)? +} diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt index d19e2f3e..a9187eb5 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt @@ -36,14 +36,11 @@ object IdentityLogic { transformedKey = transformedKey.replace("\$", "") } - when (val value = entry.value) { - is List<*> -> mergedAttributes[transformedKey] = value.filterNotNull() - is Map<*, *> -> { - val nonNullMap = value.filterValues { it != null } - mergedAttributes[transformedKey] = nonNullMap.filterKeys { it != null } - } - null -> mergedAttributes.remove(transformedKey) - else -> mergedAttributes[transformedKey] = value + val value = entry.value + if (value != null) { + mergedAttributes[transformedKey] = value + } else { + mergedAttributes.remove(transformedKey) } } 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..d5f55dd8 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -1,342 +1,114 @@ package com.superwall.sdk.identity +import com.superwall.sdk.SdkContext 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.Trackable 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.config.options.SuperwallOptions 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.primitives.StateActor 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.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 java.util.concurrent.Executors - +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * Facade over the identity state of the shared SDK actor. + * + * Implements [IdentityContext] directly — actions receive `this` as + * their context, eliminating the intermediate object. + */ class IdentityManager( - private val deviceHelper: DeviceHelper, - private val storage: Storage, - private val configManager: ConfigManager, + override val storage: Storage, private val ioScope: IOScope, - private val neverCalledStaticConfig: () -> Boolean, private val stringToSha: (String) -> String = { it }, - private val notifyUserChange: (change: Map) -> Unit, - private val completeReset: () -> Unit = { + override val notifyUserChange: (change: Map) -> Unit, + override val completeReset: () -> Unit = { Superwall.instance.reset(duringIdentify = true) }, - private val track: suspend (TrackableSuperwallEvent) -> Unit = { + private val trackEvent: suspend (TrackableSuperwallEvent) -> Unit = { Superwall.instance.track(it) }, -) { - private companion object Keys { - val appUserId = "appUserId" - val aliasId = "aliasId" - - val seed = "seed" - } - - private var _appUserId: String? = storage.read(AppUserId) - - val appUserId: String? - get() = - runBlocking(queue) { - _appUserId - } - - private var _aliasId: String = - storage.read(AliasId) ?: IdentityLogic.generateAlias() + private val options: () -> SuperwallOptions, + override val webPaywallRedeemer: () -> WebPaywallRedeemer, + override val actor: StateActor, + @Suppress("EXPOSED_PARAMETER_TYPE") + override val sdkContext: SdkContext, +) : IdentityContext { + override val scope: CoroutineScope get() = ioScope + override val track: suspend (Trackable) -> Unit = { trackEvent(it as TrackableSuperwallEvent) } + + private val identity get() = actor.state.value + + val appUserId: String? get() = identity.appUserId + val aliasId: String get() = identity.aliasId + val seed: Int get() = identity.seed + val userId: String get() = identity.userId + val userAttributes: Map get() = identity.enrichedAttributes + val isLoggedIn: Boolean get() = identity.isLoggedIn val externalAccountId: String get() = - if (configManager.options.passIdentifiersToPlayStore) { + if (options().passIdentifiersToPlayStore) { userId } else { stringToSha(userId) } - val aliasId: String - get() = - runBlocking(queue) { - _aliasId - } - - private var _seed: Int = - storage.read(Seed) ?: IdentityLogic.generateSeed() - - val seed: Int - get() = - runBlocking(queue) { - _seed - } - - val userId: String - get() = - runBlocking(queue) { - _appUserId ?: _aliasId - } - - private var _userAttributes: Map = storage.read(UserAttributes) ?: emptyMap() - - 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 isLoggedIn: Boolean get() = _appUserId != null - - private val identityFlow = MutableStateFlow(false) - val hasIdentity: Flow get() = identityFlow.asStateFlow().filter { it } - - private val queue = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val scope = CoroutineScope(queue) - private val identityJobs = CopyOnWriteArrayList() - - init { - val extraAttributes = mutableMapOf() - - val aliasId = storage.read(AliasId) - if (aliasId == null) { - storage.write(AliasId, _aliasId) - extraAttributes[Keys.aliasId] = _aliasId - } - - val seed = storage.read(Seed) - if (seed == null) { - storage.write(Seed, _seed) - extraAttributes[Keys.seed] = _seed - } - - if (extraAttributes.isNotEmpty()) { - mergeUserAttributes( - newUserAttributes = extraAttributes, - shouldTrackMerge = false, - ) - } - } - - fun configure() { - ioScope.launchWithTracking { - val neverCalledStaticConfig = neverCalledStaticConfig() - val isFirstAppOpen = - !(storage.read(DidTrackFirstSeen) ?: false) + val hasIdentity: Flow + get() = actor.state.map { it.isReady }.filter { it } - if (IdentityLogic.shouldGetAssignments( - isLoggedIn, - neverCalledStaticConfig, - isFirstAppOpen, - ) - ) { - configManager.getAssignments() - } - didSetIdentity() - } + fun configure(neverCalledStaticConfig: Boolean) { + effect( + IdentityState.Actions.Configure( + neverCalledStaticConfig = neverCalledStaticConfig, + ), + ) } 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, - ) - } - } - - fun reset(duringIdentify: Boolean) { - ioScope.launch { - identityFlow.emit(false) - } - - if (duringIdentify) { - _reset() - } else { - _reset() - didSetIdentity() - } + effect(IdentityState.Actions.Identify(userId, options)) } - @Suppress("ktlint:standard:function-naming") - private fun _reset() { - _appUserId = null - _aliasId = IdentityLogic.generateAlias() - _seed = IdentityLogic.generateSeed() - _userAttributes = emptyMap() - saveIds() + fun reset() { + effect(IdentityState.Actions.FullReset) } fun mergeUserAttributes( newUserAttributes: Map, shouldTrackMerge: Boolean = true, ) { - scope.launch { - _mergeUserAttributes( - newUserAttributes = newUserAttributes, + effect( + IdentityState.Actions.MergeAttributes( + attrs = newUserAttributes, shouldTrackMerge = shouldTrackMerge, - ) - } + shouldNotify = false, + ), + ) } internal fun mergeAndNotify( newUserAttributes: Map, shouldTrackMerge: Boolean = true, ) { - scope.launch { - _mergeUserAttributes( - newUserAttributes = newUserAttributes, + effect( + IdentityState.Actions.MergeAttributes( + 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) - } - } + suspend fun awaitLatestIdentity(): IdentityState { + return actor.state.first { state -> !state.hasPendingIdentityResolution } } } 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..390bb115 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt @@ -0,0 +1,474 @@ +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.logger.Logger +import com.superwall.sdk.misc.primitives.Reducer +import com.superwall.sdk.misc.primitives.TypedAction +import com.superwall.sdk.misc.sha256MappedToRange +import com.superwall.sdk.models.config.Config +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.web.WebPaywallRedeemer + +internal object Keys { + const val APP_USER_ID = "appUserId" + const val ALIAS_ID = "aliasId" + const val SEED = "seed" +} + +data class IdentityState( + val appUserId: String? = null, + val aliasId: String = IdentityLogic.generateAlias(), + val seed: Int = IdentityLogic.generateSeed(), + val userAttributes: Map = emptyMap(), + val phase: Phase = Phase.Pending(setOf(Pending.Configuration)), + val appInstalledAtString: String = "", +) { + sealed class Pending { + object Configuration : Pending() + data class Identification(val id: String) : Pending() + object Attributes : Pending() + object Reset : Pending() + object Seed : Pending() + object Assignments : Pending() + } + + sealed class Phase { + data class Pending(val items: Set) : Phase() + object Ready : Phase() + } + + val isReady: Boolean get() = phase is Phase.Ready + + val userId: String get() = appUserId ?: aliasId + + val isLoggedIn: Boolean get() = appUserId != null + + /** + * User attributes enriched with the current identity fields. + * + * Matches iOS behavior: `appUserId` is only included when the user has + * been explicitly identified via `identify()`. For anonymous users the + * key is absent from the map — callers that need "some user identifier" + * should use [userId] instead (which falls back to aliasId). + * + * `aliasId` is always present because every session has one. + */ + val enrichedAttributes: Map + get() = + userAttributes.toMutableMap().apply { + appUserId?.let { put(Keys.APP_USER_ID, it) } + put(Keys.ALIAS_ID, aliasId) + } + + val pending: Set + get() = (phase as? Phase.Pending)?.items ?: emptySet() + + val hasPendingIdentityResolution: Boolean + get() = + pending.any { pending -> + pending is Pending.Identification || + pending is Pending.Attributes || + pending is Pending.Reset || + pending is Pending.Seed || + pending is Pending.Assignments + } + + fun resolve(item: Pending): IdentityState { + val current = (phase as? Phase.Pending)?.items ?: return this + val next = current - item + return copy(phase = if (next.isEmpty()) Phase.Ready else Phase.Pending(next)) + } + + internal sealed class Updates( + override val reduce: (IdentityState) -> IdentityState, + ) : Reducer { + data class Identify( + val userId: String, + val restoreAssignments: Boolean, + ) : Updates({ state -> + + if (userId == state.appUserId) { + state + } else { + val base = + if (state.appUserId != null) { + IdentityState( + appInstalledAtString = state.appInstalledAtString, + phase = Phase.Ready, + ) + } else { + state + } + + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.APP_USER_ID to userId, + Keys.ALIAS_ID to base.aliasId, + Keys.SEED to base.seed, + ), + oldAttributes = base.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + + val existing = (state.phase as? Phase.Pending)?.items.orEmpty() + base.copy( + appUserId = userId, + userAttributes = merged, + phase = Phase.Pending(existing + buildSet { + add(Pending.Seed) + if (restoreAssignments) add(Pending.Assignments) + }), + ) + } + }) + + data class SeedResolved( + val seed: Int, + ) : Updates({ state -> + 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, + ) + + state + .copy(seed = seed, userAttributes = merged) + .resolve(Pending.Seed) + }) + + object SeedSkipped : Updates({ state -> + state.resolve(Pending.Seed) + }) + + data class AttributesMerged( + val attrs: Map, + ) : Updates({ state -> + val merged = + IdentityLogic.mergeAttributes( + newAttributes = attrs, + oldAttributes = state.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + state.copy(userAttributes = merged) + }) + + object AssignmentsCompleted : Updates({ state -> + state.resolve(Pending.Assignments) + }) + + data class BeginIdentify(val id: String) : Updates({ state -> + val existing = (state.phase as? Phase.Pending)?.items ?: emptySet() + state.copy(phase = Phase.Pending(existing + Pending.Identification(id))) + }) + + data class EndIdentify(val id: String) : Updates({ state -> + state.resolve(Pending.Identification(id)) + }) + + object BeginAttributes : Updates({ state -> + val existing = (state.phase as? Phase.Pending)?.items ?: emptySet() + state.copy(phase = Phase.Pending(existing + Pending.Attributes)) + }) + + object EndAttributes : Updates({ state -> + state.resolve(Pending.Attributes) + }) + + object BeginReset : Updates({ state -> + val existing = (state.phase as? Phase.Pending)?.items ?: emptySet() + state.copy(phase = Phase.Pending(existing + Pending.Reset)) + }) + + object EndReset : Updates({ state -> + state.resolve(Pending.Reset) + }) + + data class Configure( + val needsAssignments: Boolean, + ) : Updates({ state -> + // Resolve the Configuration item, optionally add Assignments, + // but preserve any existing pending items (e.g. Seed from a + // concurrent identify() that started before config was fetched). + val existing = (state.phase as? Phase.Pending)?.items ?: emptySet() + val next = + (existing - Pending.Configuration) + + (if (needsAssignments) setOf(Pending.Assignments) else emptySet()) + if (next.isEmpty()) { + state.copy(phase = Phase.Ready) + } else { + state.copy(phase = Phase.Pending(next)) + } + }) + + object Reset : Updates({ state -> + val fresh = IdentityState(appInstalledAtString = state.appInstalledAtString) + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.ALIAS_ID to fresh.aliasId, + Keys.SEED to fresh.seed, + ), + oldAttributes = emptyMap(), + appInstalledAtString = state.appInstalledAtString, + ) + val existing = (state.phase as? Phase.Pending)?.items.orEmpty() + val nextPhase = + if (existing.isEmpty()) { + Phase.Ready + } else { + Phase.Pending(existing) + } + fresh.copy( + userAttributes = merged, + phase = nextPhase, + ) + }) + + } + + internal sealed class Actions( + override val execute: suspend IdentityContext.() -> Unit, + ) : TypedAction { + data class Configure( + val neverCalledStaticConfig: Boolean, + ) : Actions({ + val isFirstAppOpen = !(storage.read(DidTrackFirstSeen) ?: false) + val needsAssignments = + IdentityLogic.shouldGetAssignments( + isLoggedIn = actor.state.value.isLoggedIn, + neverCalledStaticConfig = neverCalledStaticConfig, + isFirstAppOpen = isFirstAppOpen, + ) + update(Updates.Configure(needsAssignments = needsAssignments)) + if (needsAssignments) { + effect(FetchAssignments) + } + }) + + data class Identify( + val userId: String, + val options: IdentityOptions?, + ) : Actions({ + val sanitized = IdentityLogic.sanitize(userId) + if (sanitized.isNullOrEmpty()) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.identityManager, + message = "The provided userId was null or empty.", + ) + } else if (sanitized != state.value.appUserId) { + val wasLoggedIn = state.value.appUserId != null + + // Reset other managers BEFORE updating state so storage.reset() doesn't wipe the new IDs + if (wasLoggedIn) { + completeReset() + immediate(Reset) + } + + // Update state (pure) — persistence handled by interceptor + update(Updates.Identify(sanitized, options?.restorePaywallAssignments == true)) + + val newState = state.value + immediate( + IdentityChanged( + sanitized, + newState.aliasId, + options?.restorePaywallAssignments, + ), + ) + } + }) + + data class IdentityChanged( + val id: String, + val alias: String, + val restoreAssignments: Boolean?, + ) : Actions({ + track(InternalSuperwallEvent.IdentityAlias()) + + // Track user_attributes after identity change. + // Old code did this via saveIds() → _mergeUserAttributes(shouldTrackMerge=true). + val current = state.value + track( + InternalSuperwallEvent.Attributes( + appInstalledAtString = current.appInstalledAtString, + audienceFilterParams = HashMap(current.userAttributes), + ), + ) + + effect(ResolveSeed(id)) + effect(CheckWebEntitlements) + sdkContext.reevaluateTestMode(id, alias) + + // Fetch assignments — inline if restoring, fire-and-forget otherwise + if (restoreAssignments == true) { + immediate(FetchAssignments) + } else { + effect(FetchAssignments) + } + }) + + data class ResolveSeed( + val userId: String, + ) : Actions({ + try { + val config = sdkContext.awaitConfig() + if (config != null && config.featureFlags.enableUserIdSeed) { + userId.sha256MappedToRange()?.let { mapped -> + update(Updates.SeedResolved(mapped)) + // Track user_attributes after seed change. + // Old code did this via saveIds() → _mergeUserAttributes(shouldTrackMerge=true). + val current = state.value + track( + InternalSuperwallEvent.Attributes( + appInstalledAtString = current.appInstalledAtString, + audienceFilterParams = HashMap(current.userAttributes), + ), + ) + } ?: update(Updates.SeedSkipped) + } else { + update(Updates.SeedSkipped) + } + } catch (_: Exception) { + update(Updates.SeedSkipped) + } + }) + + object FetchAssignments : Actions({ + try { + sdkContext.fetchAssignments() + } finally { + update(Updates.AssignmentsCompleted) + } + }) + + object CheckWebEntitlements : Actions({ + webPaywallRedeemer().redeem(WebPaywallRedeemer.RedeemType.Existing) + }) + + data class MergeAttributes( + val attrs: Map, + val shouldTrackMerge: Boolean = true, + val shouldNotify: Boolean = false, + ) : Actions({ + update(Updates.AttributesMerged(attrs)) + if (shouldTrackMerge) { + val current = state.value + track( + InternalSuperwallEvent.Attributes( + appInstalledAtString = current.appInstalledAtString, + audienceFilterParams = HashMap(current.userAttributes), + ), + ) + } + if (shouldNotify) { + effect(NotifyUserChange(state.value.userAttributes)) + } + }) + + data class NotifyUserChange( + val attributes: Map, + ) : Actions({ + notifyUserChange?.invoke(attributes) + }) + + object Reset : Actions({ + update(Updates.Reset) + // Track user_attributes with the intermediate reset state during re-identify. + // Old code did this via _reset() → saveIds() → _mergeUserAttributes(shouldTrackMerge=true). + val current = state.value + track( + InternalSuperwallEvent.Attributes( + appInstalledAtString = current.appInstalledAtString, + audienceFilterParams = HashMap(current.userAttributes), + ), + ) + }) + + /** Matches iOS behavior where identitySubject is set to false during the reset window. */ + object FullReset : Actions({ + update(Updates.Reset) // identity not ready + // Track user_attributes with the new (reset) identity. + // Old code did this via _reset() → saveIds() → _mergeUserAttributes(shouldTrackMerge=true). + val current = state.value + track( + InternalSuperwallEvent.Attributes( + appInstalledAtString = current.appInstalledAtString, + audienceFilterParams = HashMap(current.userAttributes), + ), + ) + completeReset() // storage, config, paywall cache cleanup + }) + } +} + +/** + * Builds initial IdentityState from storage BEFORE the actor starts. + * This is synchronous — same as the old 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() + + // Merge when alias/seed are new OR when stored attributes are empty/missing + // (e.g. deserialization failed but individual fields were intact). + val needsMerge = storedAliasId == null || storedSeed == null || userAttributes.isEmpty() + 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, + appInstalledAtString = appInstalledAtString, + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityPendingInterceptor.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityPendingInterceptor.kt new file mode 100644 index 00000000..0744b027 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityPendingInterceptor.kt @@ -0,0 +1,28 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.misc.primitives.StateActor + +internal object IdentityPendingInterceptor { + fun install(actor: StateActor) { + actor.onAction { action, next -> + when (action) { + is IdentityState.Actions.Identify -> actor.update(IdentityState.Updates.BeginIdentify(action.userId)) + is IdentityState.Actions.MergeAttributes -> actor.update(IdentityState.Updates.BeginAttributes) + is IdentityState.Actions.FullReset -> actor.update(IdentityState.Updates.BeginReset) + } + next() + } + + actor.onActionExecution { action, next -> + try { + next() + } finally { + when (action) { + is IdentityState.Actions.Identify -> actor.update(IdentityState.Updates.EndIdentify(action.userId)) + is IdentityState.Actions.MergeAttributes -> actor.update(IdentityState.Updates.EndAttributes) + is IdentityState.Actions.FullReset -> actor.update(IdentityState.Updates.EndReset) + } + } + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityPersistenceInterceptor.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityPersistenceInterceptor.kt new file mode 100644 index 00000000..b1c3d8aa --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityPersistenceInterceptor.kt @@ -0,0 +1,39 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.misc.primitives.StateActor +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 + +/** + * Auto-persists identity fields to storage whenever state changes. + * + * Only writes fields that actually changed, so reducers that only + * touch `pending`/`isReady` (e.g. Configure, AssignmentsCompleted) + * produce zero storage writes. + */ +internal object IdentityPersistenceInterceptor { + fun install( + actor: StateActor, + storage: Storage, + ) { + actor.onUpdate { reducer, next -> + val before = actor.state.value + next(reducer) + val after = actor.state.value + + if (after.aliasId != before.aliasId) storage.write(AliasId, after.aliasId) + if (after.seed != before.seed) storage.write(Seed, after.seed) + if (after.userAttributes != before.userAttributes) storage.write(UserAttributes, after.userAttributes) + if (after.appUserId != before.appUserId) { + if (after.appUserId != null) { + storage.write(AppUserId, after.appUserId) + } else { + storage.delete(AppUserId) + } + } + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/Actor.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Actor.kt new file mode 100644 index 00000000..c9e8fc26 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Actor.kt @@ -0,0 +1,22 @@ +package com.superwall.sdk.misc.primitives + +interface Actor { + /** Fire-and-forget action dispatch. */ + fun effect( + ctx: Ctx, + action: TypedAction, + ) + + /** Dispatch action inline, suspending until it completes. */ + suspend fun immediate( + ctx: Ctx, + action: TypedAction, + ) + + /** Dispatch action, suspending until state matches [until]. */ + suspend fun immediateUntil( + ctx: Ctx, + action: TypedAction, + until: (S) -> Boolean, + ): S +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/ActorTypes.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/ActorTypes.kt new file mode 100644 index 00000000..ebcc59d4 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/ActorTypes.kt @@ -0,0 +1,82 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +/** + * A [StateActor] that serializes all action execution via a FIFO [Channel]. + * + * Actions dispatched with [effect] or [immediate] execute in dispatch order + * and never run concurrently — matching the behavior of a single-threaded + * queue dispatcher. + * + * Re-entrant: actions that call [immediate] on the same actor execute + * inline (they're already inside the consumer loop). + */ +class SequentialActor( + initial: S, + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) : StateActor(initial, scope) { + + private val queue = Channel Unit>(Channel.UNLIMITED) + + private class OwnerElement(val actor: Any) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + } + + init { + // Single consumer — FIFO ordering guaranteed. + scope.launch(OwnerElement(this@SequentialActor)) { + for (work in queue) { + work() + } + } + } + + override suspend fun executeAction( + ctx: Ctx, + action: TypedAction, + ) { + if (coroutineContext[OwnerElement]?.actor === this) { + // Re-entrant: already inside the consumer loop — run inline. + super.executeAction(ctx, action) + } else { + // External call (immediate from outside): enqueue and wait. + val done = CompletableDeferred() + queue.trySend { + try { + super.executeAction(ctx, action) + done.complete(Unit) + } catch (e: Throwable) { + done.completeExceptionally(e) + } + } + done.await() + } + } + + /** Fire-and-forget: enqueue action in FIFO order, return immediately. */ + override fun effect( + ctx: Ctx, + action: TypedAction, + ) { + runInterceptorChain(action) { + queue.trySend { + runAsyncInterceptorChain(action) { + action.execute.invoke(ctx) + } + } + } + } + + /** Closes the queue. The consumer loop exits after draining remaining items. */ + fun close() { + queue.close() + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt new file mode 100644 index 00000000..b9741a03 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt @@ -0,0 +1,32 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.storage.Storable +import com.superwall.sdk.storage.Storage + +/** + * SDK-level actor context — extends [StoreContext] with storage helpers. + * + * All Superwall domain contexts (IdentityContext, ConfigContext) extend this. + */ +interface BaseContext> : StoreContext { + val storage: Storage + + /** Persist a value to storage. */ + fun persist( + storable: Storable, + value: T, + ) { + storage.write(storable, value) + } + + fun read(storable: Storable): Result = + storage.read(storable)?.let { + Result.success(it) + } ?: Result.failure(IllegalArgumentException("Not found")) + + /** Delete a value from storage. */ + fun delete(storable: Storable<*>) { + @Suppress("UNCHECKED_CAST") + storage.delete(storable as Storable) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/DebugInterceptor.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/DebugInterceptor.kt new file mode 100644 index 00000000..3a58b392 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/DebugInterceptor.kt @@ -0,0 +1,90 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger + +/** + * Installs debug interceptors on an [StateActor] that log every action dispatch + * and state update, building a traceable timeline of what happened and why. + * + * Usage: + * ```kotlin + * val actor = Actor(initialState, scope) + * DebugInterceptor.install(actor, name = "Identity") + * ``` + * + * Output example: + * ``` + * [Identity] action → Identify(userId=user_123) + * [Identity] update → Identify | 2ms + * [Identity] update → AttributesMerged | 0ms + * [Identity] action → ResolveSeed(userId=user_123) + * [Identity] update → SeedResolved | 1ms + * ``` + */ +object DebugInterceptor { + /** + * Install debug logging on an [StateActor]. + * + * @param actor The actor to instrument. + * @param name A human-readable label for log output (e.g. "Identity", "Config"). + * @param scope The [LogScope] to log under. Defaults to [LogScope.superwallCore]. + * @param level The [LogLevel] to log at. Defaults to [LogLevel.debug]. + */ + fun install( + actor: StateActor, + name: String, + scope: LogScope = LogScope.superwallCore, + level: LogLevel = LogLevel.debug, + ) { + actor.onUpdate { reducer, next -> + val reducerName = reducer.labelOf() + val start = System.nanoTime() + next(reducer) + val elapsedMs = (System.nanoTime() - start) / 1_000_000 + Logger.debug( + logLevel = level, + scope = scope, + message = "Interceptor: [$name] update → $reducerName | ${elapsedMs}ms", + ) + } + + actor.onAction { action, next -> + val actionName = action.labelOf() + Logger.debug( + logLevel = level, + scope = scope, + message = "Interceptor: [$name] action → $actionName", + ) + next() + } + + actor.onActionExecution { action, next -> + val actionName = action.labelOf() + val start = System.nanoTime() + next() + val elapsedMs = (System.nanoTime() - start) / 1_000_000 + Logger.debug( + logLevel = level, + scope = scope, + message = "Interceptor: [$name] action ✓ $actionName | ${elapsedMs}ms", + ) + } + } + + /** + * Derive a readable label from an action or reducer instance. + * + * For sealed-class members like `IdentityState.Updates.Identify(userId=foo)`, + * this returns `"Identify(userId=foo)"` — the simple class name plus toString + * for data classes, or just the simple name for objects. + */ + private fun Any.labelOf(): String { + val cls = this::class + val simple = cls.simpleName ?: cls.qualifiedName ?: "anonymous" + // Data classes have a useful toString; objects don't — just use the name. + val str = toString() + return if (str.startsWith(simple)) str else simple + } +} 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..e2323482 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt @@ -0,0 +1,11 @@ +package com.superwall.sdk.misc.primitives + +/** + * A pure state transform — no side effects, no dispatch. + * + * Reducers are `(S) -> S`. They describe HOW state changes. + * All side effects (storage, network, tracking) belong in [TypedAction]s. + */ +interface Reducer { + val reduce: (S) -> S +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateActor.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateActor.kt new file mode 100644 index 00000000..55c0a0cf --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateActor.kt @@ -0,0 +1,194 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +fun stateActor(initial: State, scope: CoroutineScope) = StateActor(initial, scope) + +/** + * Holds state and provides synchronous updates + async action dispatching. + * + * [update] uses [MutableStateFlow.update] internally (CAS retry) — + * concurrent updates from multiple actions are safe. + * + * Dispatch modes: + * - [effect]: fire-and-forget — launches in the actor's scope. + * - [immediateUntil]: dispatch + suspend until state matches a condition. + * + * ## Interceptors + * + * ```kotlin + * actor.onUpdate { reducer, next -> + * next(reducer) // call to proceed, skip to suppress + * } + * + * actor.onAction { action, next -> + * next() // call to proceed, skip to suppress + * } + * ``` + */ + +open class StateActor( + initial: S, + internal val scope: CoroutineScope, +) : StateStore, + Actor { + private val _state = MutableStateFlow(initial) + override val state: StateFlow = _state.asStateFlow() + + // -- Interceptor chains -------------------------------------------------- + + private var updateChain: (Reducer) -> Unit = { reducer -> + _state.update { reducer.reduce(it) } + } + + private var actionInterceptors: List<(action: Any, next: () -> Unit) -> Unit> = emptyList() + + /** + * Async interceptors that wrap the suspend execution of each action. + * Unlike [onAction] (which wraps the dispatch/launch), these run + * _inside_ the coroutine and can measure wall-clock execution time. + */ + private var asyncActionInterceptors: List Unit) -> Unit> = emptyList() + + /** + * Add an update interceptor. Call `next(reducer)` to proceed, + * or skip to suppress the update. + */ + fun onUpdate(interceptor: (reducer: Reducer, next: (Reducer) -> Unit) -> Unit) { + val previous = updateChain + updateChain = { reducer -> interceptor(reducer, previous) } + } + + /** + * Add an action interceptor. Call `next()` to proceed, + * or skip to suppress the action. Action is [Any] — cast to inspect. + * + * Note: `next()` launches a coroutine and returns immediately. + * To measure action execution time, use [onActionExecution] instead. + */ + fun onAction(interceptor: (action: Any, next: () -> Unit) -> Unit) { + actionInterceptors = actionInterceptors + interceptor + } + + /** + * Add an async interceptor that wraps the action's suspend execution. + * Runs inside the coroutine — `next()` suspends until the action completes. + * + * ```kotlin + * actor.onActionExecution { action, next -> + * val start = System.nanoTime() + * next() // suspends until the action finishes + * val ms = (System.nanoTime() - start) / 1_000_000 + * println("${action::class.simpleName} took ${ms}ms") + * } + * ``` + */ + fun onActionExecution(interceptor: suspend (action: Any, next: suspend () -> Unit) -> Unit) { + asyncActionInterceptors = asyncActionInterceptors + interceptor + } + + /** Atomic state mutation using CAS retry, routed through update interceptors. */ + override fun update(reducer: Reducer) { + updateChain(reducer) + } + + /** Fire-and-forget: launch action in actor's scope, routed through interceptors. */ + override fun effect( + ctx: Ctx, + action: TypedAction, + ) { + val execute = { + scope.launch { executeAction(ctx, action) } + Unit + } + runInterceptorChain(action, execute) + } + + /** + * Dispatch action and suspend until state matches [until]. + * + * Actor-native awaiting: fire the action, observe the state transition. + */ + override suspend fun immediateUntil( + ctx: Ctx, + action: TypedAction, + until: (S) -> Boolean, + ): S { + effect(ctx, action) + return state.first { until(it) } + } + + /** + * Dispatch action inline and suspend until it completes. + * Goes through action interceptors. Use for cross-slice coordination + * where the caller needs to await the action finishing. + */ + override suspend fun immediate( + ctx: Ctx, + action: TypedAction, + ) { + var shouldExecute = true + if (actionInterceptors.isNotEmpty()) { + shouldExecute = false + var chain: () -> Unit = { shouldExecute = true } + for (i in actionInterceptors.indices.reversed()) { + val interceptor = actionInterceptors[i] + val next = chain + chain = { interceptor(action, next) } + } + chain() + } + if (shouldExecute) { + executeAction(ctx, action) + } + } + + /** Runs the action through async interceptors. Override to add serialization. */ + protected open suspend fun executeAction( + ctx: Ctx, + action: TypedAction, + ) { + runAsyncInterceptorChain(action) { action.execute.invoke(ctx) } + } + + protected suspend fun runAsyncInterceptorChain( + action: Any, + terminal: suspend () -> Unit, + ) { + if (asyncActionInterceptors.isEmpty()) { + terminal() + } else { + var chain: suspend () -> Unit = terminal + for (i in asyncActionInterceptors.indices.reversed()) { + val interceptor = asyncActionInterceptors[i] + val next = chain + chain = { interceptor(action, next) } + } + chain() + } + } + + protected fun runInterceptorChain( + action: Any, + terminal: () -> Unit, + ) { + if (actionInterceptors.isEmpty()) { + terminal() + } else { + var chain: () -> Unit = terminal + for (i in actionInterceptors.indices.reversed()) { + val interceptor = actionInterceptors[i] + val next = chain + chain = { interceptor(action, next) } + } + chain() + } + } +} + diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateStore.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateStore.kt new file mode 100644 index 00000000..1432dd26 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateStore.kt @@ -0,0 +1,16 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.flow.StateFlow + +/** + * Common interface for reading, updating, and dispatching on state. + * + * Both [StateActor] (root) and [ScopedState] (projection) implement this. + * Contexts depend on [StateStore] — they never see the concrete type. + */ +interface StateStore { + val state: StateFlow + + /** Atomic state mutation. */ + fun update(reducer: Reducer) +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt new file mode 100644 index 00000000..f49bf08e --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt @@ -0,0 +1,49 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * Pure actor context — the minimal contract for action execution. + * + * Provides a [StateStore] for state reads/updates, a [CoroutineScope], + * and a type-safe [effect] for fire-and-forget sub-action dispatch. + * + * SDK-specific concerns (storage, persistence) live in [BaseContext]. + */ +interface StoreContext> : StateStore { + val actor: StateActor + val scope: CoroutineScope + + /** Delegate state reads to the actor. */ + override val state: StateFlow get() = actor.state + + /** Apply a state reducer inline. */ + override fun update(reducer: Reducer) { + actor.update(reducer) + } + + /** + * Fire-and-forget dispatch of a sub-action on this context's actor. + * + * Type-safe: [Self] is the implementing context, matching the action's + * receiver type. The cast is guaranteed correct by the F-bounded constraint. + */ + @Suppress("UNCHECKED_CAST") + fun effect(action: TypedAction) { + actor.effect(this as Self, action) + } + + @Suppress("UNCHECKED_CAST") + suspend fun immediate(action: TypedAction) { + actor.immediate(this as Self, action) + } + + @Suppress("UNCHECKED_CAST") + suspend fun immediateUntil( + action: TypedAction, + until: (S) -> Boolean, + ) { + actor.immediateUntil(this as Self, action, until) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/TypedAction.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/TypedAction.kt new file mode 100644 index 00000000..baaa9914 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/TypedAction.kt @@ -0,0 +1,13 @@ +package com.superwall.sdk.misc.primitives + +/** + * An async operation scoped to a [Ctx] that provides all dependencies. + * + * Actions do the real work: network calls, storage writes, tracking. + * They call [StateActor.update] with pure [Reducer]s to mutate state. + * + * Actions are launched via [StateActor.action] and run concurrently. + */ +interface TypedAction { + val execute: suspend Ctx.() -> Unit +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt index 67d3baec..31b8f544 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt @@ -144,7 +144,5 @@ internal suspend fun waitForEntitlementsAndConfig( } } -// Get the identity. This may or may not wait depending on whether the dev -// specifically wants to wait for assignments. - dependencyContainer.identityManager.hasIdentity.first() + dependencyContainer.identityManager.awaitLatestIdentity() } diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt new file mode 100644 index 00000000..2e8b0235 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt @@ -0,0 +1,474 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.And +import com.superwall.sdk.SdkContext +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.primitives.SequentialActor +import com.superwall.sdk.misc.primitives.StateActor +import com.superwall.sdk.models.config.Config +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 io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Integration tests using the production [SequentialActor] (mutex-serialized) + * to verify ordering assumptions that plain StateActor tests miss. + */ +class IdentityActorIntegrationTest { + private lateinit var storage: Storage + private lateinit var deviceHelper: DeviceHelper + private lateinit var sdkContext: SdkContext + private var resetCalled = false + private var trackedEvents: MutableList = mutableListOf() + private val actors = mutableListOf>() + + private fun testActorScope(): CoroutineScope = CoroutineScope(kotlinx.coroutines.Dispatchers.IO) + + private fun installPrintlnDebug(actor: StateActor, name: String) { + actor.onUpdate { reducer, next -> + next(reducer) + println("[$name] update -> $reducer") + } + actor.onAction { action, next -> + println("[$name] action -> $action") + next() + } + actor.onActionExecution { action, next -> + try { + next() + } finally { + println("[$name] action done -> $action") + } + } + } + + @After + fun teardown() { + actors.forEach { it.close() } + actors.clear() + } + + @Before + fun setup() { + storage = mockk(relaxed = true) + deviceHelper = mockk(relaxed = true) + sdkContext = mockk(relaxed = true) + resetCalled = false + trackedEvents = mutableListOf() + + every { storage.read(AppUserId) } returns null + every { storage.read(AliasId) } returns null + every { storage.read(Seed) } returns null + every { storage.read(UserAttributes) } returns null + every { storage.read(DidTrackFirstSeen) } returns null + every { deviceHelper.appInstalledAtString } returns "2024-01-01" + + // SdkContext mocks — fetchAssignments and awaitConfig return quickly + coEvery { sdkContext.fetchAssignments() } returns Unit + coEvery { sdkContext.awaitConfig() } returns null + } + + private fun createSequentialManager( + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + existingAppUserId: String? = null, + existingAliasId: String? = null, + existingSeed: Int? = null, + ): IdentityManager { + existingAppUserId?.let { every { storage.read(AppUserId) } returns it } + existingAliasId?.let { every { storage.read(AliasId) } returns it } + existingSeed?.let { every { storage.read(Seed) } returns it } + + val initial = createInitialIdentityState(storage, "2024-01-01") + val actor = SequentialActor(initial, scope) + actors.add(actor) + installPrintlnDebug(actor, name = "IdentityTest") + IdentityPendingInterceptor.install(actor) + IdentityPersistenceInterceptor.install(actor, storage) + + return IdentityManager( + storage = storage, + options = { SuperwallOptions() }, + ioScope = IOScope(scope.coroutineContext), + notifyUserChange = {}, + completeReset = { resetCalled = true }, + trackEvent = { trackedEvents.add(it) }, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = actor, + sdkContext = sdkContext, + ) + } + + // ----------------------------------------------------------------------- + // Serialization: actions don't interleave + // ----------------------------------------------------------------------- + + @Test + fun `identify followed by mergeAttributes are serialized`() = runTest { + Given("a fresh manager with SequentialActor") { + val manager = createSequentialManager(scope = testActorScope()) + + When("identify and mergeAttributes are dispatched back-to-back") { + manager.identify("user-1") + manager.mergeUserAttributes(mapOf("key" to "value")) + manager.awaitLatestIdentity() + } + + Then("both operations completed — userId is set") { + assertEquals("user-1", manager.appUserId) + } + And("custom attribute was merged") { + assertTrue(manager.userAttributes.containsKey("key")) + assertEquals("value", manager.userAttributes["key"]) + } + } + } + + @Test + fun `configure resolves initial Configuration pending item`() = runTest { + Given("a fresh manager (phase = Pending Configuration)") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + assertFalse("should start not ready", manager.actor.state.value.isReady) + + When("configure is dispatched") { + manager.configure(neverCalledStaticConfig = false) + + manager.hasIdentity.first() + } + + Then("identity is ready") { + assertTrue(manager.actor.state.value.isReady) + } + } + } + + @Test + fun `reset gates identity readiness then restores it`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + // Make it ready first + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + assertTrue("should be ready after configure", manager.actor.state.value.isReady) + + When("FullReset is dispatched") { + manager.reset() + assertFalse(manager.actor.state.value.isReady) + manager.awaitLatestIdentity() + } + + Then("completeReset was called") { + assertTrue(resetCalled) + } + And("identity is ready again with fresh state") { + assertTrue(manager.actor.state.value.isReady) + assertNull(manager.appUserId) + } + } + } + + @Test + fun `identify then reset produces clean anonymous state`() = runTest { + Given("a manager identified as user-1") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + manager.identify("user-1") + manager.awaitLatestIdentity() + + assertEquals("user-1", manager.appUserId) + + When("reset is called") { + manager.reset() + assertFalse(manager.actor.state.value.isReady) + manager.awaitLatestIdentity() + } + + Then("appUserId is cleared") { + assertNull(manager.appUserId) + } + And("a new aliasId was generated") { + assertNotNull(manager.aliasId) + } + } + } + + // ----------------------------------------------------------------------- + // Concurrency stress: rapid-fire mutations + // ----------------------------------------------------------------------- + + @Test + fun `rapid concurrent identifies - last one wins`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + + When("multiple identifies are fired concurrently") { + manager.identify("user-1") + manager.identify("user-2") + manager.identify("user-3") + manager.identify("user-4") + manager.identify("user-5") + + Thread.sleep(200) + } + + Then("the final userId wins") { + assertEquals("user-5", manager.appUserId) + } + And("completeReset was called for user switches") { + // Each switch from one logged-in user to another triggers completeReset + assertTrue(resetCalled) + } + } + } + + @Test + fun `concurrent identifies from different coroutines`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + + When("identifies are launched from multiple coroutines simultaneously") { + val jobs = (1..10).map { i -> + launch(Dispatchers.Default) { + manager.identify("user-$i") + } + } + jobs.forEach { it.join() } + Thread.sleep(200) + } + + Then("exactly one userId survives") { + assertNotNull(manager.appUserId) + assertTrue(manager.appUserId!!.startsWith("user-")) + } + And("identity is consistent") { + assertEquals(manager.appUserId, manager.userAttributes[Keys.APP_USER_ID]) + } + } + } + + @Test + fun `reset-identify-reset-identify sequence`() = runTest { + Given("a configured ready manager identified as user-1") { + var resetCount = 0 + val manager = createSequentialManager(scope = testActorScope()) + // Override completeReset to count calls + val actor = manager.actor + val managerWithCounter = IdentityManager( + storage = storage, + options = { SuperwallOptions() }, + ioScope = IOScope(testActorScope().coroutineContext), + notifyUserChange = {}, + completeReset = { resetCount++ }, + trackEvent = { trackedEvents.add(it) }, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = actor, + sdkContext = sdkContext, + ) + + every { storage.read(DidTrackFirstSeen) } returns true + + managerWithCounter.configure(neverCalledStaticConfig = false) + managerWithCounter.hasIdentity.first() + managerWithCounter.identify("user-1") + managerWithCounter.awaitLatestIdentity() + assertEquals("user-1", managerWithCounter.appUserId) + + When("reset/identify/reset/identify is called in sequence") { + managerWithCounter.reset() + assertFalse(managerWithCounter.actor.state.value.isReady) + managerWithCounter.awaitLatestIdentity() + + managerWithCounter.identify("user-2") + managerWithCounter.awaitLatestIdentity() + + managerWithCounter.reset() + assertFalse(managerWithCounter.actor.state.value.isReady) + managerWithCounter.awaitLatestIdentity() + + managerWithCounter.identify("user-3") + managerWithCounter.awaitLatestIdentity() + } + + Then("final state is user-3") { + assertEquals("user-3", managerWithCounter.appUserId) + } + And("identity is ready") { + assertTrue(managerWithCounter.actor.state.value.isReady) + } + And("userAttributes are consistent with final identity") { + assertEquals("user-3", managerWithCounter.userAttributes[Keys.APP_USER_ID]) + } + And("completeReset was called for each reset") { + // 2 explicit resets + user switches during identify + assertTrue("resetCount should be >= 2, was $resetCount", resetCount >= 2) + } + } + } + + @Test + fun `rapid reset-identify interleaving from multiple coroutines`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + + When("resets and identifies are interleaved from concurrent coroutines") { + val jobs = (1..5).flatMap { i -> + listOf( + launch(Dispatchers.Default) { + manager.identify("user-$i") + }, + launch(Dispatchers.Default) { + manager.reset() + }, + ) + } + jobs.forEach { it.join() } + + // Final identify to ensure we end in a known state + manager.identify("final-user") + manager.awaitLatestIdentity() + } + + Then("state is consistent with the final identify call") { + val state = manager.actor.state.value + assertEquals("final-user", state.appUserId) + assertEquals("final-user", state.enrichedAttributes[Keys.APP_USER_ID]) + } + And("no crash or deadlock occurred") { + // If we got here, the mutex serialization worked correctly + assertTrue(true) + } + } + } + + // ----------------------------------------------------------------------- + // Reproduce: identify + setUserAttributes + register ordering + // ----------------------------------------------------------------------- + + @Test + fun `identify then setUserAttributes must be visible before hasIdentity returns`() = runTest { + Given("a configured manager identified as test1a with first_name = Jack") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + + manager.identify("test1a") + manager.mergeUserAttributes(mapOf("first_name" to "Jack")) + manager.awaitLatestIdentity() + + assertEquals("Jack", manager.userAttributes["first_name"]) + + When("identify as test1b then setUserAttributes Kate then wait for hasIdentity") { + manager.identify("test1b") + manager.mergeUserAttributes(mapOf("first_name" to "Kate")) + manager.awaitLatestIdentity() + } + + Then("first_name is Kate, not Jack or empty") { + assertEquals("Kate", manager.userAttributes["first_name"]) + } + And("appUserId is test1b") { + assertEquals("test1b", manager.appUserId) + } + } + } + + @Test + fun `rapid identify-setAttribute pairs preserve final attributes`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + + When("multiple identify + setAttribute pairs are fired") { + manager.identify("user-a") + manager.mergeUserAttributes(mapOf("name" to "Alice")) + + manager.identify("user-b") + manager.mergeUserAttributes(mapOf("name" to "Bob")) + + manager.identify("user-c") + manager.mergeUserAttributes(mapOf("name" to "Charlie")) + manager.awaitLatestIdentity() + } + + Then("final user is user-c with name Charlie") { + assertEquals("user-c", manager.appUserId) + assertEquals("Charlie", manager.userAttributes["name"]) + } + } + } + + // ----------------------------------------------------------------------- + // Persistence interceptor under serialization + // ----------------------------------------------------------------------- + + @Test + fun `persistence interceptor writes only changed fields`() = runTest { + Given("a fresh manager with SequentialActor") { + val manager = createSequentialManager(scope = testActorScope()) + every { storage.read(DidTrackFirstSeen) } returns true + + When("configure is dispatched (only phase changes, no identity fields)") { + manager.configure(neverCalledStaticConfig = false) + manager.hasIdentity.first() + } + + Then("no identity field writes occurred (only phase changed)") { + // The interceptor should NOT have written AliasId, Seed, etc. + // because those didn't change — only phase did. + // (Initial writes happen in createInitialIdentityState, not the interceptor) + verify(exactly = 0) { storage.write(AppUserId, any()) } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt index 07f611a9..0153bb4a 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt @@ -108,7 +108,7 @@ class IdentityLogicEnhancedTest { } @Test - fun `mergeAttributes filters null values from lists`() { + fun `mergeAttributes preserves null values in lists`() { val newAttributes = mapOf("items" to listOf("a", null, "b", null, "c")) val result = @@ -119,11 +119,11 @@ class IdentityLogicEnhancedTest { ) val items = result["items"] as List<*> - assertEquals(listOf("a", "b", "c"), items) + assertEquals(listOf("a", null, "b", null, "c"), items) } @Test - fun `mergeAttributes filters null values from maps`() { + fun `mergeAttributes preserves null values in maps`() { val newAttributes = mapOf( "metadata" to @@ -144,7 +144,7 @@ class IdentityLogicEnhancedTest { @Suppress("UNCHECKED_CAST") val metadata = result["metadata"] as Map<*, *> assertTrue(metadata.containsKey("key1")) - assertFalse(metadata.containsKey("key2")) + assertTrue(metadata.containsKey("key2")) assertTrue(metadata.containsKey("key3")) } 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 e8d6be0a..860058de 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,12 @@ import com.superwall.sdk.And import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When -import com.superwall.sdk.config.ConfigManager -import com.superwall.sdk.config.models.ConfigState +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.primitives.StateActor 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 +17,18 @@ 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.coVerify +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CoroutineScope 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 @@ -35,16 +39,45 @@ import org.junit.Test class IdentityManagerTest { private lateinit var storage: Storage - private lateinit var configManager: ConfigManager private lateinit var deviceHelper: DeviceHelper private var notifiedChanges: MutableList> = mutableListOf() private var resetCalled = false private var trackedEvents: MutableList = mutableListOf() + private fun installPrintlnDebug(actor: StateActor, name: String) { + actor.onUpdate { reducer, next -> + next(reducer) + println("[$name] update -> $reducer") + } + actor.onAction { action, next -> + println("[$name] action -> $action") + next() + } + actor.onActionExecution { action, next -> + try { + next() + } finally { + println("[$name] action done -> $action") + } + } + } + + /** Create a test identity actor using Unconfined dispatcher. */ + private fun testIdentityActor(): StateActor { + val actor = + StateActor( + createInitialIdentityState(storage, "2024-01-01"), + CoroutineScope(Dispatchers.Unconfined), + ) + installPrintlnDebug(actor, name = "IdentityTest") + IdentityPendingInterceptor.install(actor) + IdentityPersistenceInterceptor.install(actor, storage) + return actor + } + @Before fun setup() { storage = mockk(relaxed = true) - configManager = mockk(relaxed = true) deviceHelper = mockk(relaxed = true) notifiedChanges = mutableListOf() resetCalled = false @@ -56,8 +89,6 @@ class IdentityManagerTest { every { storage.read(UserAttributes) } returns null every { storage.read(DidTrackFirstSeen) } returns null every { deviceHelper.appInstalledAtString } returns "2024-01-01" - every { configManager.options } returns SuperwallOptions() - every { configManager.configState } returns MutableStateFlow(ConfigState.None) } /** @@ -70,22 +101,24 @@ class IdentityManagerTest { existingAliasId: String? = null, existingSeed: Int? = null, existingAttributes: Map? = null, - neverCalledStaticConfig: Boolean = false, + superwallOptions: SuperwallOptions = SuperwallOptions(), ): IdentityManager { existingAppUserId?.let { every { storage.read(AppUserId) } returns it } existingAliasId?.let { every { storage.read(AliasId) } returns it } existingSeed?.let { every { storage.read(Seed) } returns it } existingAttributes?.let { every { storage.read(UserAttributes) } returns it } + val scope = IOScope(dispatcher.coroutineContext) return IdentityManager( - deviceHelper = deviceHelper, storage = storage, - configManager = configManager, - ioScope = IOScope(dispatcher.coroutineContext), - neverCalledStaticConfig = { neverCalledStaticConfig }, + options = { superwallOptions }, + ioScope = scope, notifyUserChange = { notifiedChanges.add(it) }, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = testIdentityActor(), + sdkContext = mockk(relaxed = true), ) } @@ -97,20 +130,20 @@ class IdentityManagerTest { ioScope: IOScope, existingAppUserId: String? = null, existingAliasId: String? = null, - neverCalledStaticConfig: Boolean = false, ): IdentityManager { existingAppUserId?.let { every { storage.read(AppUserId) } returns it } existingAliasId?.let { every { storage.read(AliasId) } returns it } return IdentityManager( - deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { SuperwallOptions() }, ioScope = ioScope, - neverCalledStaticConfig = { neverCalledStaticConfig }, notifyUserChange = { notifiedChanges.add(it) }, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = testIdentityActor(), + sdkContext = mockk(relaxed = true), ) } @@ -183,11 +216,16 @@ class IdentityManagerTest { } @Test - fun `init does not merge attributes when alias and seed already exist`() = + fun `init does not merge attributes when alias, seed, and attributes already exist`() = runTest { - Given("existing aliasId and seed in storage") { + Given("existing aliasId, seed, and non-empty attributes in storage") { When("the manager is created") { - createManager(this@runTest, existingAliasId = "existing", existingSeed = 50) + createManager( + this@runTest, + existingAliasId = "existing", + existingSeed = 50, + existingAttributes = mapOf("aliasId" to "existing", "seed" to 50), + ) } Then("user attributes are not merged during init") { @@ -271,10 +309,12 @@ class IdentityManagerTest { fun `externalAccountId returns userId directly when passIdentifiersToPlayStore is true`() = runTest { Given("passIdentifiersToPlayStore is enabled") { - val options = SuperwallOptions().apply { passIdentifiersToPlayStore = true } - every { configManager.options } returns options - - val manager = createManager(this@runTest, existingAppUserId = "user-123") + val manager = + createManager( + this@runTest, + existingAppUserId = "user-123", + superwallOptions = SuperwallOptions().apply { passIdentifiersToPlayStore = true }, + ) val externalId = When("externalAccountId is accessed") { @@ -291,20 +331,20 @@ class IdentityManagerTest { fun `externalAccountId returns sha of userId when passIdentifiersToPlayStore is false`() = runTest { Given("passIdentifiersToPlayStore is disabled") { - val options = SuperwallOptions().apply { passIdentifiersToPlayStore = false } - every { configManager.options } returns options + val testOptions = SuperwallOptions().apply { passIdentifiersToPlayStore = false } val manager = IdentityManager( - deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { testOptions }, ioScope = IOScope(Dispatchers.Unconfined), - neverCalledStaticConfig = { false }, stringToSha = { "sha256-of-$it" }, notifyUserChange = {}, completeReset = {}, - track = {}, + trackEvent = {}, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = testIdentityActor(), + sdkContext = mockk(relaxed = true), ) val externalId = @@ -336,7 +376,8 @@ class IdentityManagerTest { val oldAlias = manager.aliasId When("reset is called not during identify") { - manager.reset(duringIdentify = false) + manager.reset() + Thread.sleep(100) } Then("appUserId is cleared") { @@ -352,30 +393,6 @@ class IdentityManagerTest { } } } - - @Test - fun `reset during identify does not emit identity`() = - runTest { - Given("a logged in user") { - val manager = createManager(this@runTest, existingAppUserId = "user-123") - - When("reset is called during identify") { - manager.reset(duringIdentify = true) - } - - 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()) } - } - } - } - - // endregion - // region identify @Test @@ -383,15 +400,11 @@ class IdentityManagerTest { runTest { Given("a fresh manager with no logged in user") { 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("new-user-456") - // Internal queue dispatches asynchronously - Thread.sleep(200) } Then("appUserId is set") { @@ -413,19 +426,16 @@ class IdentityManagerTest { runTest { Given("a manager with an existing userId") { val testScope = IOScope(this@runTest.coroutineContext) - val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val manager = createManagerWithScope(testScope) // 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") { @@ -444,7 +454,7 @@ class IdentityManagerTest { When("identify is called with an empty string") { manager.identify("") - Thread.sleep(200) + Thread.sleep(100) } Then("appUserId remains null") { @@ -462,15 +472,14 @@ class IdentityManagerTest { runTest { Given("a manager already identified with user-A") { val testScope = IOScope(this@runTest.coroutineContext) - val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState + every { storage.read(AppUserId) } returns "user-A" val manager = createManagerWithScope(testScope, existingAppUserId = "user-A") When("identify is called with a different userId") { manager.identify("user-B") - Thread.sleep(200) + Thread.sleep(100) } Then("completeReset is called") { @@ -497,16 +506,20 @@ class IdentityManagerTest { val manager = createManagerWithScope( ioScope = testScope, - neverCalledStaticConfig = true, ) - When("configure is called") { - manager.configure() - advanceUntilIdle() + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = true)) + Thread.sleep(100) } - Then("getAssignments is not called") { - coVerify(exactly = 0) { configManager.getAssignments() } + Then("identity is ready immediately without pending assignments") { + assertTrue("Identity should be ready", manager.actor.state.value.isReady) + assertFalse( + "Should not have pending assignments", + manager.actor.state.value.pending + .contains(IdentityState.Pending.Assignments), + ) } } } @@ -525,7 +538,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") { @@ -549,8 +562,7 @@ class IdentityManagerTest { mapOf("key" to "value"), shouldTrackMerge = true, ) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) } Then("an Attributes event is tracked") { @@ -572,7 +584,7 @@ class IdentityManagerTest { mapOf("key" to "value"), shouldTrackMerge = false, ) - Thread.sleep(200) + Thread.sleep(100) } Then("no event is tracked") { @@ -591,7 +603,7 @@ class IdentityManagerTest { When("mergeAndNotify is called") { manager.mergeAndNotify(mapOf("key" to "value")) - Thread.sleep(200) + Thread.sleep(100) } Then("notifyUserChange callback is invoked") { @@ -600,5 +612,494 @@ 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 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 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 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 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), + ), + ) + // Set up sdkContext mock so ResolveSeed can read the config + val sdkContext = mockk(relaxed = true) + coEvery { sdkContext.awaitConfig() } returns configWithFlag + + val manager = + IdentityManager( + storage = storage, + options = { SuperwallOptions() }, + ioScope = IOScope(this@runTest.coroutineContext), + notifyUserChange = { notifiedChanges.add(it) }, + completeReset = { resetCalled = true }, + trackEvent = { trackedEvents.add(it) }, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = testIdentityActor(), + sdkContext = sdkContext, + ) + + 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, + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = false)) + 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", + ) + + var identityReceived = false + val collectJob = + launch { + manager.hasIdentity.first() + identityReceived = true + } + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = false)) + 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 triggers assignment fetching 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", + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = true)) + Thread.sleep(100) + } + + Then("identity state reflects that assignments were requested") { + assertTrue("Identity should be ready after configure", manager.actor.state.value.isReady) + } + } + } + + @Test + fun `configure triggers assignment fetching 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, + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = true)) + Thread.sleep(100) + } + + Then("identity state reflects that assignments were requested") { + assertTrue("Identity should be ready after configure", manager.actor.state.value.isReady) + } + } + } + + @Test + fun `configure does not trigger assignments 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", + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = false)) + Thread.sleep(100) + } + + Then("identity is ready without pending assignments") { + assertTrue("Identity should be ready", manager.actor.state.value.isReady) + assertFalse( + "Should not have pending assignments", + manager.actor.state.value.pending + .contains(IdentityState.Pending.Assignments), + ) + } + } + } + + // 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() + } + + 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 aliasId but omits appUserId when anonymous`() = + 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 does NOT contain appUserId when anonymous (matches iOS)") { + val attrs = manager.userAttributes + assertFalse( + "anonymous user_attributes must not inject appUserId fallback, got: $attrs", + attrs.containsKey("appUserId"), + ) + } + } + } + + @Test + fun `userAttributes getter reflects appUserId after identify`() = + runTest { + Given("a fresh manager") { + val testScope = IOScope(this@runTest.coroutineContext) + + 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 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..b5f24c83 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt @@ -4,11 +4,9 @@ import com.superwall.sdk.And import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When -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.models.config.Config +import com.superwall.sdk.misc.primitives.StateActor import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.storage.AliasId import com.superwall.sdk.storage.AppUserId @@ -18,9 +16,9 @@ import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.UserAttributes import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers 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 @@ -38,16 +36,32 @@ import org.junit.Test */ class IdentityManagerUserAttributesTest { private lateinit var storage: Storage - private lateinit var configManager: ConfigManager private lateinit var deviceHelper: DeviceHelper private var resetCalled = false private var trackedEvents: MutableList = mutableListOf() + private fun installPrintlnDebug(actor: StateActor, name: String) { + actor.onUpdate { reducer, next -> + next(reducer) + println("[$name] update -> $reducer") + } + actor.onAction { action, next -> + println("[$name] action -> $action") + next() + } + actor.onActionExecution { action, next -> + try { + next() + } finally { + println("[$name] action done -> $action") + } + } + } + @Before fun setup() = runTest { storage = mockk(relaxed = true) - configManager = mockk(relaxed = true) deviceHelper = mockk(relaxed = true) resetCalled = false trackedEvents = mutableListOf() @@ -58,8 +72,6 @@ class IdentityManagerUserAttributesTest { every { storage.read(UserAttributes) } returns null every { storage.read(DidTrackFirstSeen) } returns null every { deviceHelper.appInstalledAtString } returns "2024-01-01" - every { configManager.options } returns SuperwallOptions() - every { configManager.configState } returns MutableStateFlow(ConfigState.None) } private fun createManager( @@ -75,17 +87,30 @@ class IdentityManagerUserAttributesTest { existingAttributes?.let { every { storage.read(UserAttributes) } returns it } return IdentityManager( - deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { SuperwallOptions() }, ioScope = IOScope(scope.coroutineContext), - neverCalledStaticConfig = { false }, notifyUserChange = {}, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = testActor(), + sdkContext = mockk(relaxed = true), ) } + private fun testActor(): StateActor { + val actor = + StateActor( + createInitialIdentityState(storage, "2024-01-01"), + CoroutineScope(Dispatchers.Unconfined), + ) + installPrintlnDebug(actor, name = "IdentityTest") + IdentityPendingInterceptor.install(actor) + IdentityPersistenceInterceptor.install(actor, storage) + return actor + } + private fun createManagerWithScope( ioScope: IOScope, existingAppUserId: String? = null, @@ -99,14 +124,15 @@ class IdentityManagerUserAttributesTest { existingAttributes?.let { every { storage.read(UserAttributes) } returns it } return IdentityManager( - deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { SuperwallOptions() }, ioScope = ioScope, - neverCalledStaticConfig = { false }, notifyUserChange = {}, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + webPaywallRedeemer = { mockk(relaxed = true) }, + actor = testActor(), + sdkContext = mockk(relaxed = true), ) } @@ -122,7 +148,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 @@ -148,17 +174,14 @@ class IdentityManagerUserAttributesTest { fun `fresh install - identify adds appUserId to userAttributes`() = runTest { Given("a fresh install") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = createManagerWithScope(testScope) 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") { @@ -238,9 +261,7 @@ class IdentityManagerUserAttributesTest { "appUserId" to "user-123", "applicationInstalledAt" to "2024-01-01", ) - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState + val testScope = IOScope(this@runTest.coroutineContext) val manager = @@ -254,8 +275,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 +309,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) @@ -322,9 +343,6 @@ class IdentityManagerUserAttributesTest { fun `BUG - returning user with empty storage, same identify, then setUserAttributes`() = runTest { Given("UserAttributes failed to load, individual IDs exist") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = @@ -338,14 +356,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 +406,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") { @@ -431,11 +449,11 @@ class IdentityManagerUserAttributesTest { ) When("reset is called") { - manager.reset(duringIdentify = false) + manager.reset() } // Allow async operations - Thread.sleep(200) + Thread.sleep(100) Then("userAttributes contains the NEW aliasId") { val attrs = manager.userAttributes @@ -467,9 +485,6 @@ class IdentityManagerUserAttributesTest { fun `reset during identify followed by new identify populates userAttributes`() = runTest { Given("a user identified as user-A") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = @@ -489,8 +504,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") { @@ -521,17 +536,14 @@ class IdentityManagerUserAttributesTest { fun `setUserAttributes does not remove identity fields`() = runTest { Given("a fresh install where init merge has completed") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = createManagerWithScope(testScope) // 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 +556,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") { @@ -572,19 +584,16 @@ class IdentityManagerUserAttributesTest { runTest { Given("a manager with identity fields in userAttributes") { val testScope = IOScope(this@runTest.coroutineContext) - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState 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") { @@ -608,15 +617,12 @@ class IdentityManagerUserAttributesTest { fun `after identify - aliasId field and userAttributes aliasId are consistent`() = runTest { Given("a fresh install") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) 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( @@ -661,10 +667,10 @@ class IdentityManagerUserAttributesTest { ) When("reset is called") { - manager.reset(duringIdentify = false) + manager.reset() } - Thread.sleep(200) + Thread.sleep(100) Then("aliasId field matches userAttributes aliasId") { assertEquals( @@ -707,7 +713,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 +746,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) diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityStateReducerTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityStateReducerTest.kt new file mode 100644 index 00000000..63ecf49a --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityStateReducerTest.kt @@ -0,0 +1,693 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.And +import com.superwall.sdk.identity.IdentityState.Pending +import com.superwall.sdk.identity.IdentityState.Phase +import com.superwall.sdk.identity.IdentityState.Updates +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure reducer tests — no coroutines, no mocks, no actor pipeline. + * Each test applies a single Updates reducer to a known state and + * asserts the output. + */ +class IdentityStateReducerTest { + + private fun readyState( + appUserId: String? = null, + aliasId: String = "alias-1", + seed: Int = 42, + userAttributes: Map = emptyMap(), + appInstalledAtString: String = "2024-01-01", + ) = IdentityState( + appUserId = appUserId, + aliasId = aliasId, + seed = seed, + userAttributes = userAttributes, + phase = Phase.Ready, + appInstalledAtString = appInstalledAtString, + ) + + private fun pendingState( + vararg items: Pending, + appUserId: String? = null, + aliasId: String = "alias-1", + seed: Int = 42, + userAttributes: Map = emptyMap(), + ) = IdentityState( + appUserId = appUserId, + aliasId = aliasId, + seed = seed, + userAttributes = userAttributes, + phase = Phase.Pending(items.toSet()), + appInstalledAtString = "2024-01-01", + ) + + // ----------------------------------------------------------------------- + // Updates.Identify + // ----------------------------------------------------------------------- + + @Test + fun `Identify - first login sets appUserId and Pending Seed`() = + Given("an anonymous ready state") { + val state = readyState() + + val result = When("Identify is applied with a new userId") { + Updates.Identify("user-1", restoreAssignments = false).reduce(state) + } + + Then("appUserId is set") { + assertEquals("user-1", result.appUserId) + } + And("phase is Pending with Seed") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + And("userAttributes contain the userId") { + assertEquals("user-1", result.userAttributes[Keys.APP_USER_ID]) + } + } + + @Test + fun `Identify - with restoreAssignments adds Assignments to pending`() = + Given("an anonymous ready state") { + val state = readyState() + + val result = When("Identify is applied with restoreAssignments = true") { + Updates.Identify("user-1", restoreAssignments = true).reduce(state) + } + + Then("phase has both Seed and Assignments pending") { + assertEquals( + Phase.Pending(setOf(Pending.Seed, Pending.Assignments)), + result.phase, + ) + } + } + + @Test + fun `Identify - same userId is a no-op`() = + Given("a logged-in state") { + val state = readyState(appUserId = "user-1") + + val result = When("Identify is applied with the same userId") { + Updates.Identify("user-1", restoreAssignments = false).reduce(state) + } + + Then("state is unchanged") { + assertEquals(state, result) + } + } + + @Test + fun `Identify - switching users resets to fresh identity`() = + Given("a logged-in state with attributes") { + val state = readyState( + appUserId = "user-1", + aliasId = "old-alias", + seed = 42, + userAttributes = mapOf("custom" to "value"), + ) + + val result = When("Identify is applied with a different userId") { + Updates.Identify("user-2", restoreAssignments = false).reduce(state) + } + + Then("appUserId is updated") { + assertEquals("user-2", result.appUserId) + } + And("aliasId is regenerated (different from old)") { + assertNotEquals("old-alias", result.aliasId) + } + And("old custom attributes are dropped") { + assertNull(result.userAttributes["custom"]) + } + And("new identity attributes are present") { + assertEquals("user-2", result.userAttributes[Keys.APP_USER_ID]) + } + } + + @Test + fun `Identify - preserves appInstalledAtString across user switch`() = + Given("a state with a specific install date") { + val state = readyState( + appUserId = "old-user", + appInstalledAtString = "2023-06-15", + ) + + val result = When("switching users") { + Updates.Identify("new-user", restoreAssignments = false).reduce(state) + } + + Then("appInstalledAtString is preserved") { + assertEquals("2023-06-15", result.appInstalledAtString) + } + } + + // ----------------------------------------------------------------------- + // Updates.SeedResolved + // ----------------------------------------------------------------------- + + @Test + fun `SeedResolved - updates seed and resolves Pending Seed`() = + Given("a state with Seed pending") { + val state = pendingState(Pending.Seed, appUserId = "user-1", seed = 42) + + val result = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 77).reduce(state) + } + + Then("seed is updated") { + assertEquals(77, result.seed) + } + And("state is Ready (no other pending items)") { + assertTrue(result.isReady) + } + And("seed is in userAttributes") { + assertEquals(77, result.userAttributes[Keys.SEED]) + } + } + + @Test + fun `SeedResolved - preserves other pending items`() = + Given("a state with Seed and Assignments pending") { + val state = pendingState(Pending.Seed, Pending.Assignments, appUserId = "user-1") + + val result = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 55).reduce(state) + } + + Then("Seed is resolved but Assignments remains") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + And("state is not ready") { + assertFalse(result.isReady) + } + } + + // ----------------------------------------------------------------------- + // Updates.SeedSkipped + // ----------------------------------------------------------------------- + + @Test + fun `SeedSkipped - resolves Pending Seed without changing seed value`() = + Given("a state with Seed pending") { + val state = pendingState(Pending.Seed, seed = 42) + + val result = When("SeedSkipped is applied") { + Updates.SeedSkipped.reduce(state) + } + + Then("seed is unchanged") { + assertEquals(42, result.seed) + } + And("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `SeedSkipped - preserves other pending items`() = + Given("a state with Seed and Assignments pending") { + val state = pendingState(Pending.Seed, Pending.Assignments) + + val result = When("SeedSkipped is applied") { + Updates.SeedSkipped.reduce(state) + } + + Then("only Assignments remains pending") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + } + + // ----------------------------------------------------------------------- + // Updates.AttributesMerged + // ----------------------------------------------------------------------- + + @Test + fun `AttributesMerged - adds new attributes`() = + Given("a state with existing attributes") { + val state = readyState(userAttributes = mapOf("existing" to "value")) + + val result = When("new attributes are merged") { + Updates.AttributesMerged(mapOf("new_key" to "new_value")).reduce(state) + } + + Then("both old and new attributes are present") { + assertEquals("value", result.userAttributes["existing"]) + assertEquals("new_value", result.userAttributes["new_key"]) + } + } + + @Test + fun `AttributesMerged - null removes attribute`() = + Given("a state with an attribute") { + val state = readyState(userAttributes = mapOf("name" to "John", "age" to 30)) + + val result = When("attribute is set to null") { + Updates.AttributesMerged(mapOf("name" to null)).reduce(state) + } + + Then("attribute is removed") { + assertFalse(result.userAttributes.containsKey("name")) + } + And("other attributes remain") { + assertEquals(30, result.userAttributes["age"]) + } + } + + @Test + fun `AttributesMerged - does not change phase`() = + Given("a pending state") { + val state = pendingState(Pending.Configuration) + + val result = When("attributes are merged") { + Updates.AttributesMerged(mapOf("key" to "val")).reduce(state) + } + + Then("phase is unchanged") { + assertEquals(Phase.Pending(setOf(Pending.Configuration)), result.phase) + } + } + + // ----------------------------------------------------------------------- + // Updates.AssignmentsCompleted + // ----------------------------------------------------------------------- + + @Test + fun `AssignmentsCompleted - resolves Pending Assignments`() = + Given("a state with only Assignments pending") { + val state = pendingState(Pending.Assignments) + + val result = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(state) + } + + Then("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `AssignmentsCompleted - no-op when Assignments not pending`() = + Given("a Ready state") { + val state = readyState() + + val result = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(state) + } + + Then("state is unchanged") { + assertEquals(state, result) + } + } + + @Test + fun `AssignmentsCompleted - preserves other pending items`() = + Given("a state with Seed and Assignments pending") { + val state = pendingState(Pending.Seed, Pending.Assignments) + + val result = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(state) + } + + Then("only Seed remains pending") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + } + + // ----------------------------------------------------------------------- + // Updates.Configure + // ----------------------------------------------------------------------- + + @Test + fun `Configure - resolves Configuration when no assignments needed`() = + Given("initial state with Configuration pending") { + val state = pendingState(Pending.Configuration) + + val result = When("Configure is applied with needsAssignments = false") { + Updates.Configure(needsAssignments = false).reduce(state) + } + + Then("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `Configure - adds Assignments when needed`() = + Given("initial state with Configuration pending") { + val state = pendingState(Pending.Configuration) + + val result = When("Configure is applied with needsAssignments = true") { + Updates.Configure(needsAssignments = true).reduce(state) + } + + Then("Configuration is resolved and Assignments is added") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + } + + @Test + fun `Configure - preserves existing Seed pending from concurrent identify`() = + Given("a state with both Configuration and Seed pending (identify ran before config)") { + val state = pendingState(Pending.Configuration, Pending.Seed) + + val result = When("Configure is applied with needsAssignments = false") { + Updates.Configure(needsAssignments = false).reduce(state) + } + + Then("Configuration is resolved but Seed remains") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + And("state is not ready") { + assertFalse(result.isReady) + } + } + + @Test + fun `Configure - preserves Seed and adds Assignments`() = + Given("a state with Configuration and Seed pending") { + val state = pendingState(Pending.Configuration, Pending.Seed) + + val result = When("Configure is applied with needsAssignments = true") { + Updates.Configure(needsAssignments = true).reduce(state) + } + + Then("both Seed and Assignments are pending") { + assertEquals( + Phase.Pending(setOf(Pending.Seed, Pending.Assignments)), + result.phase, + ) + } + } + + @Test + fun `Configure - on already Ready state with needsAssignments adds Assignments`() = + Given("a Ready state (Configure already ran or not applicable)") { + val state = readyState() + + val result = When("Configure is applied with needsAssignments = true") { + Updates.Configure(needsAssignments = true).reduce(state) + } + + Then("Assignments is pending") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + } + + @Test + fun `Configure - on already Ready state without assignments is no-op`() = + Given("a Ready state") { + val state = readyState() + + val result = When("Configure is applied with needsAssignments = false") { + Updates.Configure(needsAssignments = false).reduce(state) + } + + Then("state remains Ready") { + assertTrue(result.isReady) + } + } + + // ----------------------------------------------------------------------- + // Updates.Reset + // ----------------------------------------------------------------------- + + @Test + fun `Reset - creates fresh identity and preserves readiness`() = + Given("a logged-in state with attributes") { + val state = readyState( + appUserId = "user-1", + aliasId = "alias-1", + seed = 42, + userAttributes = mapOf("custom" to "value"), + ) + + val result = When("Reset is applied") { + Updates.Reset.reduce(state) + } + + Then("appUserId is cleared") { + assertNull(result.appUserId) + } + And("aliasId is regenerated") { + assertNotEquals("alias-1", result.aliasId) + } + And("seed is regenerated") { + // Can't assert exact value since it's random, but it exists + assertTrue(result.seed in 0..99) + } + And("custom attributes are cleared") { + assertNull(result.userAttributes["custom"]) + } + And("phase stays Ready") { + assertEquals(Phase.Ready, result.phase) + assertTrue(result.isReady) + } + And("appInstalledAtString is preserved") { + assertEquals("2024-01-01", result.appInstalledAtString) + } + And("aliasId is in userAttributes") { + assertEquals(result.aliasId, result.userAttributes[Keys.ALIAS_ID]) + } + } + + @Test + fun `Reset - from anonymous state also generates fresh identity`() = + Given("an anonymous state") { + val state = readyState(aliasId = "old-alias") + + val result = When("Reset is applied") { + Updates.Reset.reduce(state) + } + + Then("aliasId is regenerated") { + assertNotEquals("old-alias", result.aliasId) + } + And("state remains ready") { + assertTrue(result.isReady) + } + } + + // ----------------------------------------------------------------------- + // IdentityState helpers + // ----------------------------------------------------------------------- + + @Test + fun `resolve - removes item and transitions to Ready when last item`() = + Given("a state with a single pending item") { + val state = pendingState(Pending.Seed) + + val result = When("that item is resolved") { + state.resolve(Pending.Seed) + } + + Then("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `resolve - removes item but stays Pending when others remain`() = + Given("a state with multiple pending items") { + val state = pendingState(Pending.Seed, Pending.Assignments, Pending.Configuration) + + val result = When("one item is resolved") { + state.resolve(Pending.Seed) + } + + Then("remaining items are still pending") { + assertEquals( + Phase.Pending(setOf(Pending.Assignments, Pending.Configuration)), + result.phase, + ) + } + } + + @Test + fun `resolve - no-op when item not in pending set`() = + Given("a state without Assignments pending") { + val state = pendingState(Pending.Seed) + + val result = When("Assignments is resolved") { + state.resolve(Pending.Assignments) + } + + Then("state is unchanged (Seed still pending)") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + } + + @Test + fun `resolve - no-op on Ready state`() = + Given("a Ready state") { + val state = readyState() + + val result = When("any item is resolved") { + state.resolve(Pending.Seed) + } + + Then("state is unchanged") { + assertEquals(state, result) + } + } + + @Test + fun `enrichedAttributes - always includes userId and aliasId`() = + Given("a state with minimal attributes") { + val state = readyState( + appUserId = "user-1", + aliasId = "alias-1", + userAttributes = mapOf("custom" to "value"), + ) + + val enriched = When("enrichedAttributes is read") { + state.enrichedAttributes + } + + Then("it contains custom attributes") { + assertEquals("value", enriched["custom"]) + } + And("it contains appUserId") { + assertEquals("user-1", enriched[Keys.APP_USER_ID]) + } + And("it contains aliasId") { + assertEquals("alias-1", enriched[Keys.ALIAS_ID]) + } + } + + @Test + fun `enrichedAttributes - omits appUserId when anonymous`() = + Given("an anonymous state") { + val state = readyState(appUserId = null, aliasId = "alias-1") + + val enriched = When("enrichedAttributes is read") { + state.enrichedAttributes + } + + Then("appUserId key is absent (matches iOS behavior)") { + assertFalse( + "anonymous user_attributes must not inject appUserId fallback", + enriched.containsKey(Keys.APP_USER_ID), + ) + } + And("aliasId is still present") { + assertEquals("alias-1", enriched[Keys.ALIAS_ID]) + } + } + + // ----------------------------------------------------------------------- + // Composition: multi-step reducer sequences + // ----------------------------------------------------------------------- + + @Test + fun `full identify flow - Identify then SeedResolved reaches Ready`() = + Given("an anonymous ready state") { + val initial = readyState() + + val afterIdentify = When("Identify is applied") { + Updates.Identify("user-1", restoreAssignments = false).reduce(initial) + } + + Then("state is Pending Seed") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), afterIdentify.phase) + } + + val afterSeed = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 77).reduce(afterIdentify) + } + + Then("state is Ready") { + assertTrue(afterSeed.isReady) + } + And("seed is updated") { + assertEquals(77, afterSeed.seed) + } + } + + @Test + fun `full identify flow with restore - needs both Seed and Assignments`() = + Given("an anonymous ready state") { + val initial = readyState() + + val afterIdentify = When("Identify with restoreAssignments is applied") { + Updates.Identify("user-1", restoreAssignments = true).reduce(initial) + } + + Then("both Seed and Assignments are pending") { + assertEquals( + Phase.Pending(setOf(Pending.Seed, Pending.Assignments)), + afterIdentify.phase, + ) + } + + val afterSeed = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 50).reduce(afterIdentify) + } + + Then("still not ready — Assignments pending") { + assertFalse(afterSeed.isReady) + assertEquals(Phase.Pending(setOf(Pending.Assignments)), afterSeed.phase) + } + + val afterAssignments = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(afterSeed) + } + + Then("now Ready") { + assertTrue(afterAssignments.isReady) + } + } + + @Test + fun `configure then identify race - Configure preserves Seed from concurrent identify`() = + Given("initial Pending Configuration state") { + val initial = pendingState(Pending.Configuration) + + // Simulate: identify runs before configure + val afterIdentify = When("Identify runs (adding Seed) while Configuration still pending") { + // This simulates the state after identify runs but before configure + initial.copy( + phase = Phase.Pending(setOf(Pending.Configuration, Pending.Seed)), + appUserId = "user-1", + ) + } + + val afterConfigure = When("Configure runs") { + Updates.Configure(needsAssignments = false).reduce(afterIdentify) + } + + Then("Seed is preserved, Configuration is resolved") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), afterConfigure.phase) + assertFalse(afterConfigure.isReady) + } + } + + @Test + fun `reset preserves existing pending markers`() = + Given("a logged-in ready state") { + val initial = + readyState(appUserId = "user-1").copy( + phase = Phase.Pending(setOf(Pending.Reset)), + ) + + val afterReset = When("Reset is applied") { + Updates.Reset.reduce(initial) + } + + Then("reset pending is preserved") { + assertEquals(Phase.Pending(setOf(Pending.Reset)), afterReset.phase) + } + And("user is cleared") { + assertNull(afterReset.appUserId) + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt index dba78ada..5dfd8236 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt @@ -43,8 +43,7 @@ class WaitForSubsStatusAndConfigTest { every { dependencyContainer.configManager.configState } returns configState val identityManager = mockk(relaxed = true) - every { identityManager.hasIdentity } returns - MutableSharedFlow(replay = 1, extraBufferCapacity = 1).also { it.tryEmit(true) } + coEvery { identityManager.awaitLatestIdentity() } returns mockk(relaxed = true) every { dependencyContainer.identityManager } returns identityManager val request = @@ -82,8 +81,7 @@ class WaitForSubsStatusAndConfigTest { every { dependencyContainer.configManager.configState } returns configState val identityManager = mockk(relaxed = true) - every { identityManager.hasIdentity } returns - MutableSharedFlow(replay = 1, extraBufferCapacity = 1).also { it.tryEmit(true) } + coEvery { identityManager.awaitLatestIdentity() } returns mockk(relaxed = true) every { dependencyContainer.identityManager } returns identityManager mockkStatic("com.superwall.sdk.analytics.internal.TrackingKt")