diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e298601..00e59ea27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.7.15 + +## Enhancements +- Improves preloading logic to reduce number of preloaded paywalls for certain campaign types + ## 2.7.14 ## Fixes diff --git a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt index 91a952038..dd9355f99 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt @@ -64,6 +64,13 @@ class TrackingLogicTest { override fun experimentalProperties(): Map = emptyMap() override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions() + + override fun customerInfoFlow() = + kotlinx.coroutines.flow.MutableStateFlow( + com.superwall.sdk.models.customer.CustomerInfo.empty(), + ) + + override fun activeEntitlements() = emptySet() }, ), ) { diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt index 442377a01..24df1b021 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt @@ -111,6 +111,7 @@ class ConfigManagerTests { every { setEnrichment(any()) } just Runs coEvery { getTemplateDevice() } returns emptyMap() coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub()) + coEvery { preloadFingerprint() } returns "stub-fingerprint" } @Before @@ -146,7 +147,7 @@ class ConfigManagerTests { val assignments = Assignments(storage, network, backgroundScope) val preload = mockk { - coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs } @@ -188,7 +189,7 @@ class ConfigManagerTests { val assignmentStore = Assignments(storage, network, backgroundScope) val preload = mockk { - coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs } diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 91ddb4611..0e94403fe 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -1,8 +1,10 @@ package com.superwall.sdk import android.app.Application +import android.content.ComponentCallbacks2 import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.net.Uri import androidx.work.WorkManager import com.android.billingclient.api.BillingResult @@ -720,6 +722,7 @@ class Superwall( ) val event = InternalSuperwallEvent.SubscriptionStatusDidChange(newValue) track(event) + dependencyContainer.configManager.recheckPreloadIfNeeded() } } ioScope.launchWithTracking { @@ -735,8 +738,33 @@ class Superwall( dependencyContainer.storage.write(LatestCustomerInfo, new) dependencyContainer.delegateAdapter.customerInfoDidChange(old!!, new) track(CustomerInfoDidChange(old, new)) + dependencyContainer.configManager.recheckPreloadIfNeeded() } } + registerConfigurationChangeListener() + } + + private var configurationChangeListener: ComponentCallbacks2? = null + + private fun registerConfigurationChangeListener() { + // Locale / region / language / timezone / interfaceStyle (when overrides + // are off) all reflect Android Configuration. ComponentCallbacks2 fires on + // changes and runs on the main thread; bounce through ioScope to call the + // suspend recheck. + val listener = + object : ComponentCallbacks2 { + override fun onConfigurationChanged(newConfig: Configuration) { + ioScope.launchWithTracking { + dependencyContainer.configManager.recheckPreloadIfNeeded() + } + } + + override fun onLowMemory() = Unit + + override fun onTrimMemory(level: Int) = Unit + } + configurationChangeListener = listener + context.applicationContext.registerComponentCallbacks(listener) } /** @@ -780,6 +808,7 @@ class Superwall( dependencyContainer.deviceHelper.interfaceStyleOverride = interfaceStyle ioScope.launch { track(InternalSuperwallEvent.DeviceAttributes(dependencyContainer.makeSessionDeviceAttributes())) + dependencyContainer.configManager.recheckPreloadIfNeeded() } } } @@ -884,6 +913,11 @@ class Superwall( // Note: We intentionally do NOT unregister the activity lifecycle callbacks here // because the activity provider will be retained and reused in the next configure call. // This ensures the current activity is still tracked across hot reload cycles. + + configurationChangeListener?.let { + context.applicationContext.unregisterComponentCallbacks(it) + configurationChangeListener = null + } } } @@ -1409,6 +1443,7 @@ class Superwall( type = paywallEvent.type.rawValue, ), ) + dependencyContainer.configManager.recheckPreloadIfNeeded() } } @@ -1445,7 +1480,7 @@ class Superwall( ?: dependencyContainer .activityProvider ?.getCurrentActivity() - ) as SuperwallPaywallActivity? + ) as? SuperwallPaywallActivity? // Cancel any existing fallback notification of the same type before scheduling // the dynamic notification from the paywall paywallActivity?.attemptToScheduleNotifications( @@ -1456,7 +1491,7 @@ class Superwall( Logger.debug( LogLevel.error, LogScope.paywallView, - message = "No paywall activity alive to schedule notifications", + message = "No superwall paywall activity alive to schedule notifications", ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt index 71a7fc044..387c4992d 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt @@ -246,26 +246,23 @@ object ConfigLogic { // Loop through all the rules and check their preloading behavior triggerRulesPerCampaign.forEach { campaignRules -> - campaignRules.forEach { rule -> - allExperimentIds.add(rule.experiment.id) - - // Check the preloading behavior of each rule - when (rule.preload.behavior) { - TriggerPreloadBehavior.IF_TRUE -> { - val outcome = - expressionEvaluator.evaluateExpression( - rule = rule, - eventData = null, - ) - if (outcome is TriggerRuleOutcome.NoMatch) { - skippedExperimentIds.add(rule.experiment.id) - } - } - - TriggerPreloadBehavior.ALWAYS -> {} - TriggerPreloadBehavior.NEVER -> skippedExperimentIds.add(rule.experiment.id) - } + allExperimentIds.addAll(campaignRules.map { it.experiment.id }) + val ifTrueRules = campaignRules + .filter { it.preload.behavior == TriggerPreloadBehavior.IF_TRUE } + + val firstIfTrueMatch = ifTrueRules.firstOrNull { + expressionEvaluator.evaluateExpression( + rule = it, + eventData = null, + ) is TriggerRuleOutcome.Match + } + val skippedIfTrueRules = ifTrueRules.map { it.experiment.id }.filter { rule -> + rule != firstIfTrueMatch?.experiment?.id } + val skippedNeverRules = campaignRules.filter { + it.preload.behavior == TriggerPreloadBehavior.NEVER + }.map { it.experiment.id } + skippedExperimentIds.addAll(skippedIfTrueRules + skippedNeverRules) } // Remove any confirmed experiment IDs that are no longer part of a trigger @@ -320,12 +317,13 @@ object ConfigLogic { } // Returns entitlements mapped by product ID - fun extractEntitlementsByProductId(from: List) = from.associate { it.fullProductId to it.entitlements } + fun extractEntitlementsByProductId(from: List) = + from.associate { it.fullProductId to it.entitlements } // Returns entitlements mapped by product ID for CrossplatformProduct fun extractEntitlementsByProductIdFromCrossplatform(from: List) = from.associate { it.fullProductId to - it.entitlements.toSet() + it.entitlements.toSet() } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index a454bd30d..bc377ad6f 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -21,6 +21,7 @@ import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger +import com.superwall.sdk.models.triggers.TriggerPreloadBehavior import com.superwall.sdk.network.SuperwallAPI import com.superwall.sdk.network.awaitUntilNetworkExists import com.superwall.sdk.network.device.DeviceHelper @@ -142,6 +143,29 @@ open class ConfigManager( immediate(ConfigState.Actions.PreloadAll) } + /** + * Re-runs preload if any DeviceHelper field referenced by IF_TRUE rules has + * changed since the last preload. Cheap no-op when (a) config has no IF_TRUE + * rule or (b) the fingerprint matches the last dispatched preload. + * + * Call this from change-emitting signals (subscription status, configuration + * change, interface style override, store readiness, review-request increment). + */ + suspend fun recheckPreloadIfNeeded() { + val config = actor.state.value.getConfig() ?: return + val hasIfTrue = + config.triggers.any { trigger -> + trigger.rules.any { it.preload.behavior == TriggerPreloadBehavior.IF_TRUE } + } + if (!hasIfTrue) return + // Cheap pre-check; the authoritative dedup + atomic claim lives inside + // paywallPreload.preloadAllPaywalls so it commits only when a preload + // actually starts (and rolls back if the run is dropped). + val fingerprint = deviceHelper.preloadFingerprint() + if (paywallPreload.lastFingerprint.get() == fingerprint) return + immediate(ConfigState.Actions.PreloadIfEnabled) + } + suspend fun preloadPaywallsByNames(eventNames: Set) { actor.state.awaitFirstValidConfig() immediate(ConfigState.Actions.PreloadByNames(eventNames)) diff --git a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt index 5178d6271..b0c0b5793 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicReference class PaywallPreload( val factory: Factory, @@ -36,11 +37,32 @@ class PaywallPreload( private var currentPreloadingTask: Job? = null + /** + * Fingerprint of the device/store/subscription state of the most recent + * preload that actually *started* (i.e. wasn't dropped by the "already + * running" guard). Compared by ConfigManager.recheckPreloadIfNeeded to + * decide whether attribute changes warrant a re-preload. Atomic so concurrent + * rechecks don't both claim the same dispatch slot. + */ + internal val lastFingerprint: AtomicReference = AtomicReference(null) + suspend fun preloadAllPaywalls( config: Config, context: Context, + fingerprint: String? = null, ) { - if (currentPreloadingTask != null) { + if (fingerprint != null) { + val previous = lastFingerprint.get() + // Already preloaded this exact state, or another caller raced ahead. + if (previous == fingerprint) return + if (!lastFingerprint.compareAndSet(previous, fingerprint)) return + // CAS won; if the preload below is dropped, roll back so future + // rechecks can retry. + if (currentPreloadingTask != null) { + lastFingerprint.compareAndSet(fingerprint, previous) + return + } + } else if (currentPreloadingTask != null) { return } diff --git a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt index a552819d0..51a6bea17 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt @@ -356,12 +356,20 @@ sealed class ConfigState { object PreloadIfEnabled : Actions(exec@{ if (!options.computedShouldPreload(deviceHelper.deviceTier)) return@exec val config = state.value.getConfig() ?: return@exec - paywallPreload.preloadAllPaywalls(config, context) + paywallPreload.preloadAllPaywalls( + config, + context, + fingerprint = deviceHelper.preloadFingerprint(), + ) }) object PreloadAll : Actions(exec@{ val config = state.value.getConfig() ?: return@exec - paywallPreload.preloadAllPaywalls(config, context) + paywallPreload.preloadAllPaywalls( + config, + context, + fingerprint = deviceHelper.preloadFingerprint(), + ) }) data class PreloadByNames( 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 15ba4c86c..6eb6b241b 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -135,8 +135,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import com.superwall.sdk.models.serialization.DateSerializer import kotlinx.serialization.json.ClassDiscriminatorMode import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual import java.lang.ref.WeakReference import java.nio.charset.StandardCharsets import java.security.MessageDigest @@ -181,6 +184,7 @@ class DependencyContainer( ClassifierDataFactory, ExperimentalPropertiesFactory, CustomerInfoFactory, + ActiveEntitlementsFactory, WebPaywallRedeemer.Factory { internal val getPaywallComponentsFactory: GetPaywallComponentsFactory by lazy { DefaultGetPaywallComponentsFactory(Superwall.instance) @@ -773,7 +777,14 @@ class DependencyContainer( return headers } - private val paywallJson = Json { encodeDefaults = true } + private val paywallJson = + Json { + encodeDefaults = true + serializersModule = + SerializersModule { + contextual(Date::class, DateSerializer) + } + } override suspend fun makePaywallView( paywall: Paywall, @@ -1142,6 +1153,9 @@ class DependencyContainer( override fun customerInfoFlow(): StateFlow = Superwall.instance.customerInfo + override fun activeEntitlements(): Set = + entitlements.active + override fun updatePaywallInfo(paywallInfo: PaywallInfo) { Superwall.instance.presentationItems.paywallInfo = paywallInfo } @@ -1224,19 +1238,19 @@ class DependencyContainer( override suspend fun receipts(): List = googleBillingWrapper.queryAllPurchases().map { - val id = it.products.first() - val product = storeManager.products(setOf(id)).first() + val id = it.products.firstOrNull() ?: return@map null + val product = storeManager.products(setOf(id)).firstOrNull() ?: return@map null TransactionReceipt( it.purchaseToken, it.orderId, - it.products.first(), + id, if (product.rawStoreProduct?.isSubscription == true) { TransactionReceipt.ProductType.SUBSCRIPTION } else { TransactionReceipt.ProductType.IAP }, ) - } + }.filterNotNull() override fun getExternalAccountId(): String = identityManager.externalAccountId diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt index f27880c24..15b35e6ee 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -21,6 +21,7 @@ import com.superwall.sdk.misc.MainScope import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureFlags import com.superwall.sdk.models.customer.CustomerInfo +import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall @@ -152,6 +153,10 @@ fun interface CustomerInfoFactory { fun customerInfoFlow(): StateFlow } +fun interface ActiveEntitlementsFactory { + fun activeEntitlements(): Set +} + interface PresentationFactory { fun updatePaywallInfo(paywallInfo: PaywallInfo) diff --git a/superwall/src/main/java/com/superwall/sdk/models/entitlements/Entitlement.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/Entitlement.kt index 57e2279c3..21c988623 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/entitlements/Entitlement.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/entitlements/Entitlement.kt @@ -4,9 +4,11 @@ package com.superwall.sdk.models.entitlements import android.annotation.SuppressLint import com.superwall.sdk.models.product.Store +import com.superwall.sdk.models.serialization.DateSerializer import com.superwall.sdk.store.abstractions.product.receipt.LatestPeriodType import com.superwall.sdk.store.abstractions.product.receipt.LatestSubscriptionState import kotlinx.serialization.Contextual +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.* @@ -15,6 +17,7 @@ import java.util.* * An entitlement that represents a subscription tier in your app. */ @SuppressLint("UnsafeOptInUsageError") +@OptIn(ExperimentalSerializationApi::class) @Serializable data class Entitlement( /** @@ -55,6 +58,7 @@ data class Entitlement( */ @SerialName("startsAt") @Contextual + @Serializable(with = DateSerializer::class) val startsAt: Date? = null, /** * The date that the entitlement was last renewed. @@ -66,6 +70,7 @@ data class Entitlement( */ @SerialName("renewedAt") @Contextual + @Serializable(with = DateSerializer::class) val renewedAt: Date? = null, /** * The expiry date of the last transaction that unlocked this entitlement. @@ -75,6 +80,7 @@ data class Entitlement( */ @SerialName("expiresAt") @Contextual + @Serializable(with = DateSerializer::class) val expiresAt: Date? = null, /** * Indicates whether the entitlement is active for a lifetime due to the purchase of a non-consumable. diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index 98644dc3d..1319ec0f6 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -16,6 +16,8 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.DefaultClassifierDataFactory import com.superwall.sdk.analytics.DeviceClassifier import com.superwall.sdk.analytics.Tier +import com.superwall.sdk.dependencies.ActiveEntitlementsFactory +import com.superwall.sdk.dependencies.CustomerInfoFactory import com.superwall.sdk.dependencies.ExperimentalPropertiesFactory import com.superwall.sdk.dependencies.IdentityInfoFactory import com.superwall.sdk.dependencies.IdentityManagerFactory @@ -82,7 +84,9 @@ class DeviceHelper( StoreTransactionFactory, IdentityManagerFactory, ExperimentalPropertiesFactory, - OptionsFactory + OptionsFactory, + CustomerInfoFactory, + ActiveEntitlementsFactory private val json = Json { @@ -470,6 +474,40 @@ class DeviceHelper( "UNKNOWN" } + /** + * Stable fingerprint of the device/store/subscription/storage fields that can + * affect which paywalls IF_TRUE rules preload. Compared by value to decide + * whether to re-run preload after a state change. Excludes iOS-only fields + * (storeFrontCountryCode/Id/Currency, appTransactionId) and configure-time + * localResourceIds. + */ + suspend fun preloadFingerprint(): String { + val customerInfoSnapshot = factory.customerInfoFlow().value.toString() + val activeEntitlements = + factory + .activeEntitlements() + .sortedBy { it.id } + .joinToString(",") { "${it.id}:${it.type.raw}" } + val activeProducts = factory.activeProductIds().sorted().joinToString(",") + val reviewRequests = reviewRequestsTotal().toString() + + return listOf( + locale, + languageCode, + regionCode, + currencyCode, + currencySymbol, + secondsFromGMT, + interfaceStyle, + if (interfaceStyleOverride == null) "automatic" else "manual", + reviewRequests, + activeEntitlements, + customerInfoSnapshot, + activeProducts, + isSandbox.toString(), + ).joinToString("|") + } + suspend fun getDeviceAttributes( sinceEvent: EventData?, computedPropertyRequests: List, diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt index de1f9367b..72b642e71 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt @@ -1316,6 +1316,268 @@ internal class ConfigLogicTest { assertEquals("campaign_trigger", filteredTriggers.first().eventName) } + // MARK: - getAllActiveTreatmentPaywallIds with mixed IF_TRUE / ALWAYS / NEVER + // + // Semantics: + // - ALWAYS → always preloaded. + // - NEVER → never preloaded. + // - IF_TRUE → preloaded only when the rule's expression matches. Within a + // campaign, the first IF_TRUE match short-circuits any *other* + // IF_TRUE rules in that campaign (ALWAYS rules are unaffected). + + /** + * Returns Match for any rule whose experimentId is in [matchingExperimentIds], + * NoMatch otherwise. + */ + private class IfTrueEvaluator( + private val matchingExperimentIds: Set, + ) : ExpressionEvaluating { + override suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData?, + ): TriggerRuleOutcome = + if (rule.experiment.id in matchingExperimentIds) { + TriggerRuleOutcome.match(rule) + } else { + TriggerRuleOutcome.noMatch( + UnmatchedRule.Source.EXPRESSION, + rule.experiment.id, + ) + } + } + + private fun ruleWith( + experimentId: String, + behavior: TriggerPreloadBehavior, + groupId: String = "g", + ): TriggerRule = + TriggerRule.stub().apply { + this.experimentId = experimentId + this.experimentGroupId = groupId + this.preload.behavior = behavior + } + + private fun treatmentVariant( + variantId: String, + paywallId: String, + ): Experiment.Variant = + Experiment.Variant( + variantId, + Experiment.Variant.VariantType.TREATMENT, + paywallId, + ) + + @Test + fun test_getAllActiveTreatmentPaywallIds_ifTrueMatch_alongsideAlwaysAndNever() = + runTest { + // Rules: [IF_TRUE(match), ALWAYS, NEVER]. The matched IF_TRUE and the + // ALWAYS both preload; NEVER is skipped. + val expIfTrue = "expIfTrue" + val pwIfTrue = "pwIfTrue" + val expAlways = "expAlways" + val pwAlways = "pwAlways" + val expNever = "expNever" + val pwNever = "pwNever" + + val trigger = + Trigger.stub().apply { + rules = + listOf( + ruleWith(expIfTrue, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expAlways, TriggerPreloadBehavior.ALWAYS), + ruleWith(expNever, TriggerPreloadBehavior.NEVER), + ) + } + + val ids = + ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = setOf(trigger), + confirmedAssignments = + mapOf( + expIfTrue to treatmentVariant("v1", pwIfTrue), + expAlways to treatmentVariant("v2", pwAlways), + expNever to treatmentVariant("v3", pwNever), + ), + unconfirmedAssignments = emptyMap(), + expressionEvaluator = IfTrueEvaluator(setOf(expIfTrue)), + ) + + assertEquals(setOf(pwIfTrue, pwAlways), ids) + } + + @Test + fun test_getAllActiveTreatmentPaywallIds_firstIfTrueMatch_skipsLaterIfTrue() = + runTest { + // Rules: [IF_TRUE(match), IF_TRUE(would-also-match), ALWAYS]. Only the + // first IF_TRUE match should preload — the later IF_TRUE is skipped + // — but the ALWAYS rule still preloads independently. + val expIfTrue1 = "expIfTrue1" + val pwIfTrue1 = "pwIfTrue1" + val expIfTrue2 = "expIfTrue2" + val pwIfTrue2 = "pwIfTrue2" + val expAlways = "expAlways" + val pwAlways = "pwAlways" + + val trigger = + Trigger.stub().apply { + rules = + listOf( + ruleWith(expIfTrue1, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expIfTrue2, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expAlways, TriggerPreloadBehavior.ALWAYS), + ) + } + + val ids = + ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = setOf(trigger), + confirmedAssignments = + mapOf( + expIfTrue1 to treatmentVariant("v1", pwIfTrue1), + expIfTrue2 to treatmentVariant("v2", pwIfTrue2), + expAlways to treatmentVariant("v3", pwAlways), + ), + unconfirmedAssignments = emptyMap(), + // Both IF_TRUE rules would match in isolation; the first wins. + expressionEvaluator = IfTrueEvaluator(setOf(expIfTrue1, expIfTrue2)), + ) + + assertEquals(setOf(pwIfTrue1, pwAlways), ids) + } + + @Test + fun test_getAllActiveTreatmentPaywallIds_alwaysFirst_ifTrueNoMatch_neverLast() = + runTest { + // Rules: [ALWAYS, IF_TRUE(no match), NEVER]. The ALWAYS rule still + // preloads. The IF_TRUE rule skips itself because its expression did + // not match. NEVER is always skipped. + val expAlways = "expAlways" + val pwAlways = "pwAlways" + val expIfTrue = "expIfTrue" + val pwIfTrue = "pwIfTrue" + val expNever = "expNever" + val pwNever = "pwNever" + + val trigger = + Trigger.stub().apply { + rules = + listOf( + ruleWith(expAlways, TriggerPreloadBehavior.ALWAYS), + ruleWith(expIfTrue, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expNever, TriggerPreloadBehavior.NEVER), + ) + } + + val ids = + ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = setOf(trigger), + confirmedAssignments = + mapOf( + expAlways to treatmentVariant("v1", pwAlways), + expIfTrue to treatmentVariant("v2", pwIfTrue), + expNever to treatmentVariant("v3", pwNever), + ), + unconfirmedAssignments = emptyMap(), + expressionEvaluator = IfTrueEvaluator(matchingExperimentIds = emptySet()), + ) + + assertEquals(setOf(pwAlways), ids) + } + + @Test + fun test_getAllActiveTreatmentPaywallIds_sixRuleCombo_onlyFirstIfTrueMatchPlusAlways() = + runTest { + // Rules: [IF_TRUE(match), IF_TRUE(match), IF_TRUE(no match), ALWAYS, + // IF_TRUE(match), NEVER]. + // + // Among IF_TRUE rules only the first match (IFT1) should preload. + // IFT2 and IFT5 are skipped even though they'd individually match — + // the campaign already picked IFT1. IFT3 doesn't match anyway. + // ALWAYS preloads independently; NEVER is skipped. + val expIft1 = "expIft1" + val pwIft1 = "pwIft1" + val expIft2 = "expIft2" + val pwIft2 = "pwIft2" + val expIft3 = "expIft3" + val pwIft3 = "pwIft3" + val expAlways = "expAlways" + val pwAlways = "pwAlways" + val expIft5 = "expIft5" + val pwIft5 = "pwIft5" + val expNever = "expNever" + val pwNever = "pwNever" + + val trigger = + Trigger.stub().apply { + rules = + listOf( + ruleWith(expIft1, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expIft2, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expIft3, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expAlways, TriggerPreloadBehavior.ALWAYS), + ruleWith(expIft5, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expNever, TriggerPreloadBehavior.NEVER), + ) + } + + val ids = + ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = setOf(trigger), + confirmedAssignments = + mapOf( + expIft1 to treatmentVariant("v1", pwIft1), + expIft2 to treatmentVariant("v2", pwIft2), + expIft3 to treatmentVariant("v3", pwIft3), + expAlways to treatmentVariant("v4", pwAlways), + expIft5 to treatmentVariant("v5", pwIft5), + expNever to treatmentVariant("v6", pwNever), + ), + unconfirmedAssignments = emptyMap(), + // IFT1, IFT2, IFT5 match; IFT3 does not. + expressionEvaluator = IfTrueEvaluator(setOf(expIft1, expIft2, expIft5)), + ) + + assertEquals(setOf(pwIft1, pwAlways), ids) + } + + @Test + fun test_getAllActiveTreatmentPaywallIds_neverFirst_ifTrueMatchMiddle_alwaysLast() = + runTest { + // Rules: [NEVER, IF_TRUE(match), ALWAYS]. NEVER is skipped; the + // matched IF_TRUE and the trailing ALWAYS both preload. + val expNever = "expNever" + val pwNever = "pwNever" + val expIfTrue = "expIfTrue" + val pwIfTrue = "pwIfTrue" + val expAlways = "expAlways" + val pwAlways = "pwAlways" + + val trigger = + Trigger.stub().apply { + rules = + listOf( + ruleWith(expNever, TriggerPreloadBehavior.NEVER), + ruleWith(expIfTrue, TriggerPreloadBehavior.IF_TRUE), + ruleWith(expAlways, TriggerPreloadBehavior.ALWAYS), + ) + } + + val ids = + ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = setOf(trigger), + confirmedAssignments = + mapOf( + expNever to treatmentVariant("v1", pwNever), + expIfTrue to treatmentVariant("v2", pwIfTrue), + expAlways to treatmentVariant("v3", pwAlways), + ), + unconfirmedAssignments = emptyMap(), + expressionEvaluator = IfTrueEvaluator(setOf(expIfTrue)), + ) + + assertEquals(setOf(pwIfTrue, pwAlways), ids) + } + @Test fun test_filterTriggers_disableNone() { val disabled = diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt index aa2bb4943..33af093c3 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt @@ -15,6 +15,8 @@ import com.superwall.sdk.models.config.RawFeatureFlag import com.superwall.sdk.models.enrichment.Enrichment import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.triggers.Trigger +import com.superwall.sdk.models.triggers.TriggerPreloadBehavior +import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.network.NetworkError import com.superwall.sdk.network.SuperwallAPI import com.superwall.sdk.network.device.DeviceHelper @@ -129,6 +131,7 @@ class ConfigManagerTest { every { setEnrichment(any()) } just Runs coEvery { getTemplateDevice() } returns emptyMap() coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub()) + coEvery { preloadFingerprint() } returns "stub-fingerprint" } val storeManager = storeManagerOverride @@ -138,9 +141,10 @@ class ConfigManagerTest { } val preload = mockk { - coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + every { lastFingerprint } returns java.util.concurrent.atomic.AtomicReference(null) } val paywallManager = mockk(relaxed = true) val webRedeemer = mockk(relaxed = true) @@ -409,7 +413,7 @@ class ConfigManagerTest { s.manager.preloadAllPaywalls() advanceUntilIdle() - coVerify(exactly = 1) { s.preload.preloadAllPaywalls(any(), any()) } + coVerify(exactly = 1) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @Test @@ -703,7 +707,7 @@ class ConfigManagerTest { s.manager.configState.first { it is ConfigState.Retrieved } advanceUntilIdle() - coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @Test @@ -721,7 +725,7 @@ class ConfigManagerTest { s.manager.configState.first { it is ConfigState.Retrieved } advanceUntilIdle() - coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any()) } + coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @Test @@ -742,14 +746,14 @@ class ConfigManagerTest { s.manager.applyRetrievedConfigForTesting(configWithTriggers) // Reset the mock so we only count post-assignment preloads. io.mockk.clearMocks(s.preload, answers = false) - coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + coEvery { s.preload.preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { s.preload.preloadPaywallsByNames(any(), any()) } just Runs coEvery { s.preload.removeUnusedPaywallVCsFromCache(any(), any()) } just Runs s.manager.getAssignments() advanceUntilIdle() - coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any()) } + coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @Test @@ -868,7 +872,7 @@ class ConfigManagerTest { val newConfig = config(buildId = "new", enableRefresh = true) val paywallManager = mockk(relaxed = true) val preload = mockk(relaxed = true) { - coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs } @@ -932,7 +936,7 @@ class ConfigManagerTest { val s = setup(backgroundScope) s.manager.reset() advanceUntilIdle() - coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @Test @@ -956,14 +960,14 @@ class ConfigManagerTest { val s = setup(backgroundScope) val job = launch { s.manager.preloadAllPaywalls() } delay(50) - coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } val cfg = Config.stub().copy(buildId = "preload-all") s.manager.applyRetrievedConfigForTesting(cfg) job.join() advanceUntilIdle() - coVerify(exactly = 1) { s.preload.preloadAllPaywalls(eq(cfg), any()) } + coVerify(exactly = 1) { s.preload.preloadAllPaywalls(eq(cfg), any(), any()) } } @Test @@ -1176,7 +1180,7 @@ class ConfigManagerTest { advanceUntilIdle() coVerifyOrder { - s.preload.preloadAllPaywalls(any(), any()) + s.preload.preloadAllPaywalls(any(), any(), any()) s.network.getConfig(any()) } assertTrue(getCalls.get() >= 2) @@ -1446,7 +1450,7 @@ class ConfigManagerTest { coEvery { loadPurchasedProducts(any()) } just Runs }, preload = mockk(relaxed = true) { - coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadAllPaywalls(any(), any(), any()) } just Runs coEvery { preloadPaywallsByNames(any(), any()) } just Runs coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs }, @@ -1538,6 +1542,118 @@ class ConfigManagerTest { assertEquals(1, emissions.size) assertEquals("first", emissions.single().buildId) } + + // MARK: - recheckPreloadIfNeeded + + private fun triggerWith(behavior: TriggerPreloadBehavior, experimentId: String = "exp"): Trigger = + Trigger.stub().apply { + rules = + listOf( + TriggerRule.stub().apply { + this.experimentId = experimentId + this.preload.behavior = behavior + }, + ) + } + + /** Wires `lastFingerprint` on a PaywallPreload mock to a real AtomicReference. */ + private fun bindFingerprintHolder(preload: PaywallPreload): java.util.concurrent.atomic.AtomicReference { + val holder = java.util.concurrent.atomic.AtomicReference(null) + every { preload.lastFingerprint } returns holder + return holder + } + + @Test + fun `recheckPreloadIfNeeded is a no-op when config has no IF_TRUE rule`() = + runTest(timeout = 5.seconds) { + val s = setup(backgroundScope) + bindFingerprintHolder(s.preload) + coEvery { s.deviceHelper.preloadFingerprint() } returns "X" + + s.manager.applyRetrievedConfigForTesting( + config(triggers = setOf(triggerWith(TriggerPreloadBehavior.ALWAYS))), + ) + advanceUntilIdle() + + // Ignore any preload calls triggered by config retrieval itself. + io.mockk.clearMocks(s.preload, answers = false, recordedCalls = true) + bindFingerprintHolder(s.preload) + coEvery { s.preload.preloadAllPaywalls(any(), any(), any()) } just Runs + + s.manager.recheckPreloadIfNeeded() + advanceUntilIdle() + + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } + } + + @Test + fun `recheckPreloadIfNeeded is a no-op when fingerprint has not changed`() = + runTest(timeout = 5.seconds) { + val s = setup(backgroundScope) + val holder = bindFingerprintHolder(s.preload) + holder.set("SAME") + coEvery { s.deviceHelper.preloadFingerprint() } returns "SAME" + + s.manager.applyRetrievedConfigForTesting( + config(triggers = setOf(triggerWith(TriggerPreloadBehavior.IF_TRUE))), + ) + advanceUntilIdle() + + io.mockk.clearMocks(s.preload, answers = false, recordedCalls = true) + every { s.preload.lastFingerprint } returns holder + coEvery { s.preload.preloadAllPaywalls(any(), any(), any()) } just Runs + + s.manager.recheckPreloadIfNeeded() + advanceUntilIdle() + + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } + } + + @Test + fun `recheckPreloadIfNeeded dispatches preload when fingerprint changes`() = + runTest(timeout = 5.seconds) { + // shouldPreload = true so PreloadIfEnabled's gate doesn't short-circuit. + val s = setup(backgroundScope, shouldPreload = true) + val holder = bindFingerprintHolder(s.preload) + holder.set("OLD") + coEvery { s.deviceHelper.preloadFingerprint() } returns "NEW" + + val cfg = config(triggers = setOf(triggerWith(TriggerPreloadBehavior.IF_TRUE))) + s.manager.applyRetrievedConfigForTesting(cfg) + advanceUntilIdle() + + io.mockk.clearMocks(s.preload, answers = false, recordedCalls = true) + every { s.preload.lastFingerprint } returns holder + coEvery { s.preload.preloadAllPaywalls(any(), any(), any()) } just Runs + + s.manager.recheckPreloadIfNeeded() + advanceUntilIdle() + + coVerify(exactly = 1) { s.preload.preloadAllPaywalls(eq(cfg), any(), eq("NEW")) } + } + + @Test + fun `recheckPreloadIfNeeded does not preload when shouldPreload is disabled`() = + runTest(timeout = 5.seconds) { + // shouldPreload = false → PreloadIfEnabled's gate must short-circuit. + val s = setup(backgroundScope, shouldPreload = false) + val holder = bindFingerprintHolder(s.preload) + holder.set("OLD") + coEvery { s.deviceHelper.preloadFingerprint() } returns "NEW" + + val cfg = config(triggers = setOf(triggerWith(TriggerPreloadBehavior.IF_TRUE))) + s.manager.applyRetrievedConfigForTesting(cfg) + advanceUntilIdle() + + io.mockk.clearMocks(s.preload, answers = false, recordedCalls = true) + every { s.preload.lastFingerprint } returns holder + coEvery { s.preload.preloadAllPaywalls(any(), any(), any()) } just Runs + + s.manager.recheckPreloadIfNeeded() + advanceUntilIdle() + + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } + } } /**