From ff0c244bfa5677aa57a10ea35f17dff63e8dad62 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 14:48:35 +0200 Subject: [PATCH 1/8] Update how IF_TRUE campaigns are preloaded --- CHANGELOG.md | 5 + .../com/superwall/sdk/config/ConfigLogic.kt | 40 ++- .../superwall/sdk/config/ConfigLogicTest.kt | 262 ++++++++++++++++++ 3 files changed, 286 insertions(+), 21 deletions(-) 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/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/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 = From 74cca76c983346d05398155b9791d76fdbe0214d Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 15:27:11 +0200 Subject: [PATCH 2/8] Minor fixes for null safety + date safety --- .../main/java/com/superwall/sdk/Superwall.kt | 4 ++-- .../sdk/dependencies/DependencyContainer.kt | 20 ++++++++++++++----- .../sdk/models/entitlements/Entitlement.kt | 6 ++++++ .../paywall/view/SuperwallPaywallActivity.kt | 2 ++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 91ddb4611..9a6d087d7 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -1445,7 +1445,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 +1456,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/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 15ba4c86c..449116f31 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 @@ -773,7 +776,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, @@ -1224,19 +1234,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/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/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 7066e0224..9f805d480 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -816,6 +816,8 @@ class SuperwallPaywallActivity : AppCompatActivity() { } } + + suspend fun attemptToScheduleNotifications( notifications: List, factory: DeviceHelperFactory, From b25e51bac01049e417a561bafeb094fd4872d470 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 15:53:15 +0200 Subject: [PATCH 3/8] Add fingerprinting of current device state for smarter preload retrigger --- .../main/java/com/superwall/sdk/Superwall.kt | 35 +++++++ .../com/superwall/sdk/config/ConfigManager.kt | 22 +++++ .../superwall/sdk/config/PaywallPreload.kt | 9 ++ .../sdk/config/models/ConfigState.kt | 2 + .../sdk/network/device/DeviceHelper.kt | 40 ++++++++ .../superwall/sdk/config/ConfigManagerTest.kt | 99 +++++++++++++++++++ 6 files changed, 207 insertions(+) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 9a6d087d7..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() } } 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..687e0f16f 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,27 @@ 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 + val fingerprint = deviceHelper.preloadFingerprint() + if (paywallPreload.lastFingerprint == fingerprint) return + paywallPreload.lastFingerprint = fingerprint + immediate(ConfigState.Actions.PreloadAll) + } + 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..7fb5563fc 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -36,6 +36,15 @@ class PaywallPreload( private var currentPreloadingTask: Job? = null + /** + * Fingerprint of the device/store/subscription state at the time of the most + * recent preload dispatch. Used by ConfigManager.recheckPreloadIfNeeded to + * decide whether attribute changes warrant a re-preload. Seeded by the + * PreloadIfEnabled / PreloadAll actions on every dispatch. + */ + @Volatile + internal var lastFingerprint: String? = null + suspend fun preloadAllPaywalls( config: Config, context: Context, 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..ec2e00ba7 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,11 +356,13 @@ sealed class ConfigState { object PreloadIfEnabled : Actions(exec@{ if (!options.computedShouldPreload(deviceHelper.deviceTier)) return@exec val config = state.value.getConfig() ?: return@exec + paywallPreload.lastFingerprint = deviceHelper.preloadFingerprint() paywallPreload.preloadAllPaywalls(config, context) }) object PreloadAll : Actions(exec@{ val config = state.value.getConfig() ?: return@exec + paywallPreload.lastFingerprint = deviceHelper.preloadFingerprint() paywallPreload.preloadAllPaywalls(config, context) }) 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..24ba1e1a9 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 @@ -470,6 +470,46 @@ 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 = + runCatching { Superwall.instance.getCustomerInfo().toString() } + .getOrDefault("") + val activeEntitlements = + runCatching { + Superwall.instance.entitlements.active + .sortedBy { it.id } + .joinToString(",") { "${it.id}:${it.type.raw}" } + }.getOrDefault("") + val activeProducts = + runCatching { factory.activeProductIds().sorted().joinToString(",") } + .getOrDefault("") + val reviewRequests = + runCatching { reviewRequestsTotal().toString() }.getOrDefault("0") + + 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/ConfigManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt index aa2bb4943..3ab8dad78 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 @@ -1538,6 +1540,103 @@ 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` get/set on a PaywallPreload mock to a real holder. */ + private fun bindFingerprintHolder(preload: PaywallPreload): java.util.concurrent.atomic.AtomicReference { + val holder = java.util.concurrent.atomic.AtomicReference(null) + every { preload.lastFingerprint } answers { holder.get() } + every { preload.lastFingerprint = any() } answers { holder.set(firstArg()) } + 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, + ) + // Re-bind the property stubs after clearMocks (which wipes answers). + bindFingerprintHolder(s.preload) + coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + + s.manager.recheckPreloadIfNeeded() + advanceUntilIdle() + + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(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 } answers { holder.get() } + every { s.preload.lastFingerprint = any() } answers { holder.set(firstArg()) } + coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + + s.manager.recheckPreloadIfNeeded() + advanceUntilIdle() + + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + } + + @Test + fun `recheckPreloadIfNeeded dispatches preload when fingerprint changes`() = + runTest(timeout = 5.seconds) { + val s = setup(backgroundScope) + 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 } answers { holder.get() } + every { s.preload.lastFingerprint = any() } answers { holder.set(firstArg()) } + coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + + s.manager.recheckPreloadIfNeeded() + advanceUntilIdle() + + coVerify(exactly = 1) { s.preload.preloadAllPaywalls(eq(cfg), any()) } + assertEquals("NEW", holder.get()) + } } /** From 8ed391385f53b24c3460f3ae316c33948105bc94 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 16:16:51 +0200 Subject: [PATCH 4/8] Ensure no silent drops of fingerprints --- .../com/superwall/sdk/config/ConfigManager.kt | 6 ++-- .../superwall/sdk/config/PaywallPreload.kt | 27 +++++++++++----- .../sdk/config/models/ConfigState.kt | 14 ++++++--- .../sdk/dependencies/DependencyContainer.kt | 4 +++ .../sdk/dependencies/FactoryProtocols.kt | 5 +++ .../sdk/network/device/DeviceHelper.kt | 26 +++++++--------- .../paywall/view/SuperwallPaywallActivity.kt | 2 -- .../superwall/sdk/config/ConfigManagerTest.kt | 31 +++++++------------ 8 files changed, 66 insertions(+), 49 deletions(-) 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 687e0f16f..2a4c59b03 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -158,9 +158,11 @@ open class ConfigManager( 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 == fingerprint) return - paywallPreload.lastFingerprint = fingerprint + if (paywallPreload.lastFingerprint.get() == fingerprint) return immediate(ConfigState.Actions.PreloadAll) } 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 7fb5563fc..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, @@ -37,19 +38,31 @@ class PaywallPreload( private var currentPreloadingTask: Job? = null /** - * Fingerprint of the device/store/subscription state at the time of the most - * recent preload dispatch. Used by ConfigManager.recheckPreloadIfNeeded to - * decide whether attribute changes warrant a re-preload. Seeded by the - * PreloadIfEnabled / PreloadAll actions on every dispatch. + * 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. */ - @Volatile - internal var lastFingerprint: String? = null + 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 ec2e00ba7..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,14 +356,20 @@ sealed class ConfigState { object PreloadIfEnabled : Actions(exec@{ if (!options.computedShouldPreload(deviceHelper.deviceTier)) return@exec val config = state.value.getConfig() ?: return@exec - paywallPreload.lastFingerprint = deviceHelper.preloadFingerprint() - paywallPreload.preloadAllPaywalls(config, context) + paywallPreload.preloadAllPaywalls( + config, + context, + fingerprint = deviceHelper.preloadFingerprint(), + ) }) object PreloadAll : Actions(exec@{ val config = state.value.getConfig() ?: return@exec - paywallPreload.lastFingerprint = deviceHelper.preloadFingerprint() - 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 449116f31..6eb6b241b 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -184,6 +184,7 @@ class DependencyContainer( ClassifierDataFactory, ExperimentalPropertiesFactory, CustomerInfoFactory, + ActiveEntitlementsFactory, WebPaywallRedeemer.Factory { internal val getPaywallComponentsFactory: GetPaywallComponentsFactory by lazy { DefaultGetPaywallComponentsFactory(Superwall.instance) @@ -1152,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 } 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/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index 24ba1e1a9..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 { @@ -478,20 +482,14 @@ class DeviceHelper( * localResourceIds. */ suspend fun preloadFingerprint(): String { - val customerInfoSnapshot = - runCatching { Superwall.instance.getCustomerInfo().toString() } - .getOrDefault("") + val customerInfoSnapshot = factory.customerInfoFlow().value.toString() val activeEntitlements = - runCatching { - Superwall.instance.entitlements.active - .sortedBy { it.id } - .joinToString(",") { "${it.id}:${it.type.raw}" } - }.getOrDefault("") - val activeProducts = - runCatching { factory.activeProductIds().sorted().joinToString(",") } - .getOrDefault("") - val reviewRequests = - runCatching { reviewRequestsTotal().toString() }.getOrDefault("0") + factory + .activeEntitlements() + .sortedBy { it.id } + .joinToString(",") { "${it.id}:${it.type.raw}" } + val activeProducts = factory.activeProductIds().sorted().joinToString(",") + val reviewRequests = reviewRequestsTotal().toString() return listOf( locale, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 9f805d480..7066e0224 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -816,8 +816,6 @@ class SuperwallPaywallActivity : AppCompatActivity() { } } - - suspend fun attemptToScheduleNotifications( notifications: List, factory: DeviceHelperFactory, 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 3ab8dad78..67ab6e749 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt @@ -1554,11 +1554,10 @@ class ConfigManagerTest { ) } - /** Wires `lastFingerprint` get/set on a PaywallPreload mock to a real holder. */ + /** 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 } answers { holder.get() } - every { preload.lastFingerprint = any() } answers { holder.set(firstArg()) } + every { preload.lastFingerprint } returns holder return holder } @@ -1575,19 +1574,14 @@ class ConfigManagerTest { advanceUntilIdle() // Ignore any preload calls triggered by config retrieval itself. - io.mockk.clearMocks( - s.preload, - answers = false, - recordedCalls = true, - ) - // Re-bind the property stubs after clearMocks (which wipes answers). + io.mockk.clearMocks(s.preload, answers = false, recordedCalls = true) bindFingerprintHolder(s.preload) - coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + coEvery { s.preload.preloadAllPaywalls(any(), any(), any()) } just Runs s.manager.recheckPreloadIfNeeded() advanceUntilIdle() - coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @Test @@ -1604,14 +1598,13 @@ class ConfigManagerTest { advanceUntilIdle() io.mockk.clearMocks(s.preload, answers = false, recordedCalls = true) - every { s.preload.lastFingerprint } answers { holder.get() } - every { s.preload.lastFingerprint = any() } answers { holder.set(firstArg()) } - coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + 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()) } + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any(), any()) } } @Test @@ -1627,15 +1620,13 @@ class ConfigManagerTest { advanceUntilIdle() io.mockk.clearMocks(s.preload, answers = false, recordedCalls = true) - every { s.preload.lastFingerprint } answers { holder.get() } - every { s.preload.lastFingerprint = any() } answers { holder.set(firstArg()) } - coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + 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()) } - assertEquals("NEW", holder.get()) + coVerify(exactly = 1) { s.preload.preloadAllPaywalls(eq(cfg), any(), eq("NEW")) } } } From 00230eeab1686954fdc66c194a2299dac30d52c9 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 16:30:42 +0200 Subject: [PATCH 5/8] Fix tests --- .../superwall/sdk/config/ConfigManagerTest.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) 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 67ab6e749..dc619a079 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt @@ -131,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 @@ -140,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) @@ -411,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 @@ -705,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 @@ -723,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 @@ -744,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 @@ -870,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 } @@ -934,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 @@ -958,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 @@ -1178,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) @@ -1448,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 }, From a00f114867e4a1bbdda55d1bd59ee6f66ac1fdb9 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 16:56:42 +0200 Subject: [PATCH 6/8] Ensure recheck only preloads if enabled --- .../analytics/internal/TrackingLogicTest.kt | 7 +++++ .../com/superwall/sdk/config/ConfigManager.kt | 2 +- .../superwall/sdk/config/ConfigManagerTest.kt | 26 ++++++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) 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/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index 2a4c59b03..bc377ad6f 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -163,7 +163,7 @@ open class ConfigManager( // actually starts (and rolls back if the run is dropped). val fingerprint = deviceHelper.preloadFingerprint() if (paywallPreload.lastFingerprint.get() == fingerprint) return - immediate(ConfigState.Actions.PreloadAll) + immediate(ConfigState.Actions.PreloadIfEnabled) } suspend fun preloadPaywallsByNames(eventNames: Set) { 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 dc619a079..33af093c3 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt @@ -1612,7 +1612,8 @@ class ConfigManagerTest { @Test fun `recheckPreloadIfNeeded dispatches preload when fingerprint changes`() = runTest(timeout = 5.seconds) { - val s = setup(backgroundScope) + // 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" @@ -1630,6 +1631,29 @@ class ConfigManagerTest { 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()) } + } } /** From f00470bacea8e3a8d256b8ba86870132774c8508 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 17:52:38 +0200 Subject: [PATCH 7/8] Config test failure fix --- .../com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt | 1 + 1 file changed, 1 insertion(+) 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..c1577829f 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 From 8c01aaa3304504dfb9a96d88193a033728b534f8 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 18 May 2026 18:12:05 +0200 Subject: [PATCH 8/8] Fix wrong signature of config test --- .../com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c1577829f..24df1b021 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt @@ -147,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 } @@ -189,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 }