diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index bb5b45e8d..9a401d145 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index 6adbbd285..8790d333a 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/scripts/superwall_timeline_diff_tool.html b/scripts/superwall_timeline_diff_tool.html
new file mode 100644
index 000000000..758d8aef3
--- /dev/null
+++ b/scripts/superwall_timeline_diff_tool.html
@@ -0,0 +1,465 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 e53d0db21..442377a01 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt
@@ -6,86 +6,78 @@ import Then
import When
import android.app.Application
import android.content.Context
-import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.superwall.sdk.Superwall
+import com.superwall.sdk.analytics.Tier
import com.superwall.sdk.config.models.ConfigState
-import com.superwall.sdk.config.models.getConfig
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.dependencies.DependencyContainer
import com.superwall.sdk.misc.Either
import com.superwall.sdk.misc.IOScope
+import com.superwall.sdk.misc.primitives.SequentialActor
import com.superwall.sdk.models.assignment.Assignment
import com.superwall.sdk.models.assignment.ConfirmableAssignment
import com.superwall.sdk.models.config.Config
-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.Experiment
import com.superwall.sdk.models.triggers.Trigger
import com.superwall.sdk.models.triggers.TriggerRule
import com.superwall.sdk.models.triggers.VariantOption
-import com.superwall.sdk.network.Network
-import com.superwall.sdk.network.NetworkError
import com.superwall.sdk.network.NetworkMock
import com.superwall.sdk.network.SuperwallAPI
import com.superwall.sdk.network.device.DeviceHelper
import com.superwall.sdk.paywall.manager.PaywallManager
import com.superwall.sdk.storage.CONSTANT_API_KEY
-import com.superwall.sdk.storage.LatestConfig
-import com.superwall.sdk.storage.LatestEnrichment
import com.superwall.sdk.storage.LatestRedemptionResponse
-import com.superwall.sdk.storage.LocalStorage
import com.superwall.sdk.storage.Storage
import com.superwall.sdk.storage.StorageMock
import com.superwall.sdk.storage.StoredEntitlementsByProductId
import com.superwall.sdk.storage.StoredSubscriptionStatus
-import com.superwall.sdk.storage.core_data.convertToJsonElement
import com.superwall.sdk.store.Entitlements
import com.superwall.sdk.store.StoreManager
import com.superwall.sdk.web.WebPaywallRedeemer
import io.mockk.Runs
-import io.mockk.clearMocks
import io.mockk.coEvery
-import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
-import io.mockk.spyk
-import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
-import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
-import kotlinx.serialization.json.JsonObject
-import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
-class ConfigManagerUnderTest(
- private val context: Context,
- private val storage: Storage,
- private val network: SuperwallAPI,
- private val paywallManager: PaywallManager,
- private val storeManager: StoreManager,
- private val factory: Factory,
- private val deviceHelper: DeviceHelper,
- private val assignments: Assignments,
- private val paywallPreload: PaywallPreload,
- private val ioScope: CoroutineScope,
+// Storage round-trip integration tests. Pure logic lives in src/test/.
+open class ConfigManagerUnderTest(
+ context: Context,
+ storage: Storage,
+ network: SuperwallAPI,
+ paywallManager: PaywallManager,
+ storeManager: StoreManager,
+ factory: Factory,
+ deviceHelper: DeviceHelper,
+ assignments: Assignments,
+ paywallPreload: PaywallPreload,
+ ioScope: CoroutineScope,
+ private val testOptions: SuperwallOptions = SuperwallOptions(),
+ testEntitlements: Entitlements =
+ Entitlements(
+ mockk(relaxUnitFun = true) {
+ every { read(StoredSubscriptionStatus) } returns SubscriptionStatus.Unknown
+ every { read(StoredEntitlementsByProductId) } returns emptyMap()
+ every { read(LatestRedemptionResponse) } returns null
+ },
+ ),
+ webRedeemer: WebPaywallRedeemer = mockk(relaxed = true),
) : ConfigManager(
context = context,
storage = storage,
@@ -94,37 +86,31 @@ class ConfigManagerUnderTest(
storeManager = storeManager,
factory = factory,
deviceHelper = deviceHelper,
- options = SuperwallOptions(),
+ options = testOptions,
assignments = assignments,
paywallPreload = paywallPreload,
ioScope = IOScope(ioScope.coroutineContext),
- track = {},
- entitlements =
- Entitlements(
- mockk(relaxUnitFun = true) {
- every { read(StoredSubscriptionStatus) } returns SubscriptionStatus.Unknown
- every { read(StoredEntitlementsByProductId) } returns emptyMap()
- every { read(LatestRedemptionResponse) } returns null
- },
- ),
- awaitUtilNetwork = {},
- webPaywallRedeemer = { mockk(relaxed = true) },
+ tracker = {},
+ entitlements = testEntitlements,
+ webPaywallRedeemer = { webRedeemer },
+ actor = SequentialActor(ConfigState.None, IOScope(ioScope.coroutineContext)),
) {
- suspend fun setConfig(config: Config) {
- configState.emit(ConfigState.Retrieved(config))
+ fun setConfig(config: Config) {
+ applyRetrievedConfigForTesting(config)
}
}
@RunWith(AndroidJUnit4::class)
class ConfigManagerTests {
- val mockDeviceHelper =
+ private val mockDeviceHelper =
mockk {
every { appVersion } returns "1.0"
every { locale } returns "en-US"
+ every { deviceTier } returns Tier.MID
+ every { bundleId } returns "com.test"
+ every { setEnrichment(any()) } just Runs
coEvery { getTemplateDevice() } returns emptyMap()
- coEvery {
- getEnrichment(any(), any())
- } returns Either.Success(Enrichment.stub())
+ coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub())
}
@Before
@@ -142,9 +128,7 @@ class ConfigManagerTests {
fun test_confirmAssignment() =
runTest(timeout = 5.minutes) {
Given("we have a ConfigManager with a mock assignment") {
- // get context
val context = InstrumentationRegistry.getInstrumentation().targetContext
-
val experimentId = "abc"
val variantId = "def"
val variant =
@@ -194,106 +178,11 @@ class ConfigManagerTests {
}
}
- @Test
- fun test_loadAssignments_noConfig() =
- runTest(timeout = 5.minutes) {
- Given("we have a ConfigManager with no config") {
- // get context
- val context = InstrumentationRegistry.getInstrumentation().targetContext
- val dependencyContainer =
- DependencyContainer(context, null, null, activityProvider = null, apiKey = "")
- val network = NetworkMock()
- val storage = StorageMock(context = context, coroutineScope = backgroundScope)
- val assignments = Assignments(storage, network, backgroundScope)
- val preload =
- mockk {
- coEvery { preloadAllPaywalls(any(), any()) } just Runs
- coEvery { preloadPaywallsByNames(any(), any()) } just Runs
- coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
- }
-
- val configManager =
- ConfigManagerUnderTest(
- context = context,
- storage = storage,
- network = network,
- paywallManager = dependencyContainer.paywallManager,
- storeManager = dependencyContainer.storeManager,
- factory = dependencyContainer,
- deviceHelper = mockDeviceHelper,
- assignments = assignments,
- paywallPreload = preload,
- ioScope = backgroundScope,
- )
-
- Log.e("test", "test_loadAssignments_noConfig")
- When("we try to get assignments") {
- val job =
- launch {
- configManager.getAssignments()
- ensureActive()
- assert(false) // Make sure we never get here...
- }
- delay(1000)
- job.cancel()
- }
-
- Log.e("test", "test_loadAssignments_noConfig")
- Then("no assignments should be stored") {
- assertTrue(storage.getConfirmedAssignments().isEmpty())
- assertTrue(configManager.unconfirmedAssignments.isEmpty())
- }
- }
- return@runTest
- }
-
- @Test
- fun test_loadAssignments_noTriggers() =
- runTest(timeout = 5.minutes) {
- Given("we have a ConfigManager with a config that has no triggers") {
- // get context
- val context = InstrumentationRegistry.getInstrumentation().targetContext
-
- val dependencyContainer =
- DependencyContainer(context, null, null, activityProvider = null, apiKey = "")
- val network = NetworkMock()
- val storage = StorageMock(context = context, coroutineScope = backgroundScope)
- val assignments = Assignments(storage, network, backgroundScope)
- val configManager =
- ConfigManagerUnderTest(
- context = context,
- storage = storage,
- network = network,
- paywallManager = dependencyContainer.paywallManager,
- storeManager = dependencyContainer.storeManager,
- factory = dependencyContainer,
- deviceHelper = mockDeviceHelper,
- assignments = assignments,
- paywallPreload = preload,
- ioScope = backgroundScope,
- )
- configManager.setConfig(
- Config.stub().apply { this.triggers = emptySet() },
- )
-
- When("we get assignments") {
- configManager.getAssignments()
- }
-
- Then("no assignments should be stored") {
- assertTrue(storage.getConfirmedAssignments().isEmpty())
- assertTrue(configManager.unconfirmedAssignments.isEmpty())
- }
- }
- }
-
@Test
fun test_loadAssignments_saveAssignmentsFromServer() =
runTest(timeout = 30.seconds) {
Given("we have a ConfigManager with assignments from the server") {
- // get context
val context = InstrumentationRegistry.getInstrumentation().targetContext
-
val network = NetworkMock()
val storage = StorageMock(context = context, coroutineScope = backgroundScope)
val assignmentStore = Assignments(storage, network, backgroundScope)
@@ -319,11 +208,8 @@ class ConfigManagerTests {
val variantId = "variantId"
val experimentId = "experimentId"
-
val assignments: List =
- listOf(
- Assignment(experimentId = experimentId, variantId = variantId),
- )
+ listOf(Assignment(experimentId = experimentId, variantId = variantId))
network.assignments = assignments.toMutableList()
val variantOption = VariantOption.stub().apply { id = variantId }
@@ -357,547 +243,5 @@ class ConfigManagerTests {
assertTrue(configManager.unconfirmedAssignments.isEmpty())
}
}
- return@runTest
}
-
- @Test
- fun should_refresh_config_successfully() =
- runTest(timeout = Duration.INFINITE) {
- Given("we have a ConfigManager with an old config") {
- val mockNetwork =
- mockk {
- coEvery { getConfig(any()) } returns
- Either.Success(
- Config.stub(),
- )
- coEvery {
- getEnrichment(
- any(),
- any(),
- any(),
- )
- } returns Either.Success(mockk())
- }
- val context = InstrumentationRegistry.getInstrumentation().targetContext
- val dependencyContainer =
- DependencyContainer(context, null, null, activityProvider = null, apiKey = "")
- val storage = StorageMock(context = context, coroutineScope = backgroundScope)
- val oldConfig =
- Config.stub().copy(
- rawFeatureFlags =
- listOf(
- RawFeatureFlag("enable_config_refresh_v2", true),
- ),
- )
-
- val mockPaywallManager =
- mockk {
- every { resetPaywallRequestCache() } just Runs
- every { currentView } returns null
- }
-
- val mockContainer =
- spyk(dependencyContainer) {
- every { deviceHelper } returns mockDeviceHelper
- every { paywallManager } returns mockPaywallManager
- }
- val assignments = Assignments(storage, mockNetwork, backgroundScope)
-
- val testId = "123"
- val configManager =
- spyk(
- ConfigManagerUnderTest(
- context,
- storage,
- mockNetwork,
- mockPaywallManager,
- dependencyContainer.storeManager,
- mockContainer,
- mockDeviceHelper,
- assignments = assignments,
- paywallPreload = preload,
- ioScope = backgroundScope,
- ),
- ) {
- every { config } returns oldConfig.copy(requestId = testId)
- }
-
- When("we refresh the configuration") {
- Superwall.configure(
- context.applicationContext as Application,
- "pk_test_1234",
- null,
- null,
- null,
- null,
- )
- configManager.refreshConfiguration()
- }
-
- Then("the config should be refreshed and the paywall cache reset") {
- coVerify { mockNetwork.getConfig(any()) }
- verify { mockPaywallManager.resetPaywallRequestCache() }
- assertTrue(configManager.config?.requestId === testId)
- }
- }
- }
-
- @Test
- fun should_fail_refreshing_config_and_keep_old_config() =
- runTest(timeout = Duration.INFINITE) {
- Given("we have a ConfigManager with an old config and a network that fails") {
- val context = InstrumentationRegistry.getInstrumentation().targetContext
- val mockNetwork =
- mockk {
- coEvery { getConfig(any()) } returns Either.Failure(NetworkError.Unknown())
- coEvery { getEnrichment(any(), any(), any()) } returns Either.Success(mockk())
- }
- val dependencyContainer =
- DependencyContainer(context, null, null, activityProvider = null, apiKey = "")
- val storage = StorageMock(context = context, coroutineScope = backgroundScope)
- val oldConfig =
- Config.stub().copy(
- rawFeatureFlags =
- listOf(
- RawFeatureFlag("enable_config_refresh_v2", true),
- ),
- )
-
- val mockPaywallManager =
- mockk {
- every { resetPaywallRequestCache() } just Runs
- every { currentView } returns null
- }
-
- val mockContainer =
- spyk(dependencyContainer) {
- every { deviceHelper } returns mockDeviceHelper
- every { paywallManager } returns mockPaywallManager
- }
- val assignments = Assignments(storage, mockNetwork, backgroundScope)
-
- val testId = "123"
- val configManager =
- spyk(
- ConfigManagerUnderTest(
- context,
- storage,
- mockNetwork,
- mockPaywallManager,
- dependencyContainer.storeManager,
- mockContainer,
- mockDeviceHelper,
- assignments = assignments,
- paywallPreload = preload,
- ioScope = backgroundScope,
- ),
- ) {
- every { config } returns oldConfig.copy(requestId = testId)
- }
-
- When("we try to refresh the configuration") {
- configManager.refreshConfiguration()
-
- Then("the old config should be kept") {
- coVerify { mockNetwork.getConfig(any()) }
- assertTrue(configManager.config?.requestId === testId)
- }
- }
- }
- }
-
- private val storage =
- mockk {
- coEvery { write(any(), any()) } just Runs
- coEvery { read(LatestRedemptionResponse) } returns null
- coEvery { read(StoredEntitlementsByProductId) } returns emptyMap()
- }
- private val dependencyContainer =
- mockk {
- coEvery { makeSessionDeviceAttributes() } returns hashMapOf()
- coEvery { provideRuleEvaluator(any()) } returns mockk()
- }
-
- private val manager =
- mockk {
- every { resetPaywallRequestCache() } just Runs
- every { resetCache() } just Runs
- }
- private val storeKit =
- mockk {
- coEvery { products(any()) } returns emptySet()
- coEvery { loadPurchasedProducts(any()) } just Runs
- }
- private val preload =
- mockk {
- coEvery { preloadAllPaywalls(any(), any()) } just Runs
- coEvery { preloadPaywallsByNames(any(), any()) } just Runs
- coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
- }
-
- private val localStorage =
- mockk {
- every { getConfirmedAssignments() } returns emptyMap()
- every { saveConfirmedAssignments(any()) } just Runs
- coEvery { read(LatestRedemptionResponse) } returns null
- coEvery { read(StoredEntitlementsByProductId) } returns emptyMap()
- }
- private val mockNetwork = mockk()
-
- @Test
- fun test_network_delay_with_cached_version() =
- runTest(timeout = 5.minutes) {
- Given("we have a cached config and a delayed network response") {
- val cachedConfig =
- Config.stub().copy(
- buildId = "cached",
- rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh_v2", true)),
- )
- val newConfig = Config.stub().copy(buildId = "not")
-
- coEvery { storage.read(LatestRedemptionResponse) } returns null
- coEvery { localStorage.read(LatestRedemptionResponse) } returns null
- coEvery { storage.read(LatestConfig) } returns cachedConfig
- coEvery { storage.write(any(), any()) } just Runs
- coEvery { storage.read(LatestEnrichment) } returns Enrichment.stub()
- coEvery { mockNetwork.getConfig(any()) } coAnswers {
- delay(1200)
- Either.Success(newConfig)
- }
- coEvery { mockNetwork.getEnrichment(any(), any(), any()) } coAnswers {
- delay(1200)
- Either.Success(Enrichment.stub())
- }
-
- coEvery {
- mockDeviceHelper.getEnrichment(any(), any())
- } coAnswers {
- delay(1200)
- Either.Success(Enrichment.stub())
- }
-
- val context = InstrumentationRegistry.getInstrumentation().targetContext
-
- val dependencyContainer =
- DependencyContainer(context, null, null, activityProvider = null, apiKey = "")
- val mockContainer =
- spyk(dependencyContainer) {
- every { deviceHelper } returns mockDeviceHelper
- every { paywallManager } returns manager
- }
-
- val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope)
- val configManager =
- ConfigManagerUnderTest(
- context = context,
- storage = storage,
- network = mockNetwork,
- paywallManager = mockContainer.paywallManager,
- storeManager = mockContainer.storeManager,
- factory = mockContainer,
- deviceHelper = mockDeviceHelper,
- assignments = assignmentStore,
- paywallPreload = preload,
- ioScope = backgroundScope,
- )
-
- When("we fetch the configuration") {
- configManager.fetchConfiguration()
-
- Then("the cached config should be used initially") {
- coVerify(exactly = 1) { storage.read(LatestConfig) }
- configManager.configState.first { it is ConfigState.Retrieved }
- assertEquals("cached", configManager.config?.buildId)
- advanceUntilIdle()
- }
- }
- }
- }
-
- @Test
- fun test_network_delay_without_cached_version() =
- runTest(timeout = 5.minutes) {
- Given("we have no cached config and a delayed network response") {
- coEvery { storage.read(LatestRedemptionResponse) } returns null
- coEvery { storage.read(LatestConfig) } returns null
- coEvery { localStorage.read(LatestRedemptionResponse) } returns null
- coEvery { localStorage.read(LatestEnrichment) } returns null
- coEvery { storage.read(LatestEnrichment) } returns null
- coEvery {
- mockDeviceHelper.getEnrichment(any(), any())
- } returns Either.Failure(NetworkError.Unknown())
- coEvery {
- mockNetwork.getEnrichment(any(), any(), any())
- } returns Either.Failure(NetworkError.Unknown())
- coEvery { mockNetwork.getConfig(any()) } coAnswers {
- delay(1200)
- Either.Success(Config.stub().copy(buildId = "not"))
- }
- coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap()
-
- val context = InstrumentationRegistry.getInstrumentation().targetContext
-
- val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope)
- val configManager =
- ConfigManagerUnderTest(
- context = context,
- storage = storage,
- network = mockNetwork,
- paywallManager = manager,
- storeManager = storeKit,
- factory = dependencyContainer,
- deviceHelper = mockDeviceHelper,
- assignments = assignmentStore,
- paywallPreload = preload,
- ioScope = backgroundScope,
- )
-
- When("we fetch the configuration") {
- configManager.fetchConfiguration()
-
- And("we wait for it to be retrieved") {
- configManager.configState.first { it is ConfigState.Retrieved }
-
- Then("the new config should be fetched exactly once and used") {
- coVerify(exactly = 1) { mockNetwork.getConfig(any()) }
- assertEquals("not", configManager.config?.buildId)
- }
- }
- }
- }
- }
-
- @Test
- fun test_network_failure_with_cached_version() =
- runTest(timeout = 5.minutes) {
- Given("we have a cached config and a network failure") {
- val cachedConfig =
- Config.stub().copy(
- buildId = "cached",
- rawFeatureFlags =
- listOf(
- RawFeatureFlag("enable_config_refresh_v2", true),
- ),
- )
- coEvery { storage.read(LatestRedemptionResponse) } returns null
- coEvery { localStorage.read(LatestRedemptionResponse) } returns null
- coEvery { storage.read(LatestConfig) } returns cachedConfig
- coEvery { mockNetwork.getConfig(any()) } returns Either.Failure(NetworkError.Unknown())
- coEvery { localStorage.read(LatestEnrichment) } returns null
- coEvery { storage.read(LatestEnrichment) } returns null
- coEvery {
- mockNetwork.getEnrichment(any(), any(), any())
- } returns Either.Failure(NetworkError.Unknown())
-
- coEvery {
- mockDeviceHelper.getEnrichment(any(), any())
- } returns Either.Failure(NetworkError.Unknown())
-
- coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap()
-
- val context = InstrumentationRegistry.getInstrumentation().targetContext
-
- val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope)
- val configManager =
- ConfigManagerUnderTest(
- context = context,
- storage = storage,
- network = mockNetwork,
- paywallManager = manager,
- storeManager = storeKit,
- factory = dependencyContainer,
- deviceHelper = mockDeviceHelper,
- assignments = assignmentStore,
- paywallPreload = preload,
- ioScope = backgroundScope,
- )
-
- When("we fetch the configuration") {
- configManager.fetchConfiguration()
-
- Then("the cached config should be used") {
- configManager.configState.first { it is ConfigState.Retrieved }
- coEvery { mockNetwork.getConfig(any()) } returns
- Either.Success(
- Config.stub().copy(buildId = "not"),
- )
- assertEquals("cached", configManager.config?.buildId)
-
- And("the network becomes available and we fetch again") {
- coEvery { mockNetwork.getConfig(any()) } returns
- Either.Success(
- Config.stub().copy(buildId = "not"),
- )
-
- Then("the new config should be set and used") {
- configManager.configState
- .onEach {
- println("$it is ${it::class}")
- }.drop(1)
- .first { it is ConfigState.Retrieved }
- assertEquals("not", configManager.config?.buildId)
- }
- }
- }
- }
- }
- }
-
- @Test
- fun test_quick_network_success() =
- runTest {
- Given("we have a quick network response") {
- val newConfig = Config.stub().copy(buildId = "not")
- coEvery { storage.read(LatestRedemptionResponse) } returns null
- coEvery { localStorage.read(LatestRedemptionResponse) } returns null
- coEvery { storage.read(LatestConfig) } returns null
- coEvery { mockNetwork.getConfig(any()) } coAnswers {
- delay(200)
- Either.Success(newConfig)
- }
-
- coEvery { localStorage.read(LatestEnrichment) } returns null
- coEvery { storage.read(LatestEnrichment) } returns null
- coEvery {
- mockNetwork.getEnrichment(any(), any(), any())
- } returns Either.Success(Enrichment.stub())
-
- coEvery {
- mockDeviceHelper.getEnrichment(any(), any())
- } returns Either.Success(Enrichment.stub())
-
- val context = InstrumentationRegistry.getInstrumentation().targetContext
-
- val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope)
- val preload =
- mockk {
- coEvery { preloadAllPaywalls(any(), any()) } just Runs
- coEvery { preloadPaywallsByNames(any(), any()) } just Runs
- coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
- }
-
- val configManager =
- ConfigManagerUnderTest(
- context = context,
- storage = storage,
- network = mockNetwork,
- paywallManager = manager,
- storeManager = storeKit,
- factory = dependencyContainer,
- deviceHelper = mockDeviceHelper,
- assignments = assignmentStore,
- paywallPreload = preload,
- ioScope = backgroundScope,
- )
-
- When("we fetch the configuration") {
- configManager.fetchConfiguration()
-
- Then("the new config should be used immediately") {
- assertEquals("not", configManager.config?.buildId)
- }
- }
- return@runTest
- }
- }
-
- @Test
- fun test_config_and_geo_calls_both_cached() =
- runTest(timeout = 500.seconds) {
- Given("we have cached config and geo info, and delayed network responses") {
- val cachedConfig =
- Config.stub().copy(
- buildId = "cached",
- rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh_v2", true)),
- )
- val newConfig = Config.stub().copy(buildId = "not")
- val cachedGeo = Enrichment.stub().copy()
- val newGeo = Enrichment.stub().copy(_device = JsonObject(mapOf("demandTier" to "gold".convertToJsonElement())))
-
- coEvery { preload.preloadAllPaywalls(any(), any()) } just Runs
- coEvery { storage.read(LatestRedemptionResponse) } returns null
- coEvery { localStorage.read(LatestRedemptionResponse) } returns null
- coEvery { storage.read(LatestConfig) } returns cachedConfig
- coEvery { storage.read(LatestEnrichment) } returns cachedGeo
- coEvery { storage.write(any(), any()) } just Runs
- coEvery { localStorage.read(LatestEnrichment) } returns cachedGeo
- every { manager.resetPaywallRequestCache() } just Runs
- coEvery { preload.removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
- var callCount = 0
- coEvery { mockNetwork.getConfig(any()) } coAnswers {
- if (callCount == 0) {
- callCount += 1
- delay(5000)
- }
- Either.Success(newConfig)
- }
- var enrichmentCallCount = 0
- every { mockDeviceHelper.setEnrichment(any()) } just Runs
- coEvery { mockDeviceHelper.getEnrichment(any(), any()) } coAnswers {
- enrichmentCallCount += 1
- if (enrichmentCallCount == 1) {
- delay(5000)
- Either.Failure(NetworkError.Timeout)
- } else {
- delay(100)
- Either.Success(newGeo)
- }
- }
- coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap()
-
- val context = InstrumentationRegistry.getInstrumentation().targetContext
-
- val dependencyContainer =
- DependencyContainer(context, null, null, activityProvider = null, apiKey = "")
-
- val mockContainer =
- spyk(dependencyContainer) {
- every { deviceHelper } returns mockDeviceHelper
- every { paywallManager } returns manager
- }
-
- val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope)
- val configManager =
- ConfigManagerUnderTest(
- context = context,
- storage = storage,
- network = mockNetwork,
- paywallManager = mockContainer.paywallManager,
- storeManager = mockContainer.storeManager,
- factory = mockContainer,
- deviceHelper = mockDeviceHelper,
- assignments = assignmentStore,
- paywallPreload = preload,
- ioScope = backgroundScope,
- )
-
- When("we fetch the configuration") {
- configManager.fetchConfiguration()
-
- Then("the cached config and geo info should be used initially") {
- configManager.configState.first { it is ConfigState.Retrieved }.also {
- assertEquals("cached", it.getConfig()?.buildId)
- }
- coEvery { mockNetwork.getConfig(any()) } coAnswers {
- delay(100)
- Either.Success(newConfig)
- }
-
- And("we wait until new config is available") {
- configManager.configState.drop(1).first { it is ConfigState.Retrieved }
-
- Then("the new config and geo info should be fetched and used") {
- assertEquals("not", configManager.config?.buildId)
- advanceUntilIdle()
- }
- }
- }
- }
- }
- }
-
- @After
- fun tearDown() {
- clearMocks(dependencyContainer, manager, storage, preload, localStorage, mockNetwork)
- }
}
diff --git a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt
index 5e52d3785..e40283266 100644
--- a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt
+++ b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt
@@ -10,7 +10,7 @@ import com.superwall.sdk.models.config.Config
* Keeps the identity slice decoupled from concrete manager types.
*/
interface SdkContext {
- fun reevaluateTestMode(appUserId: String?, aliasId: String?)
+ suspend fun reevaluateTestMode(appUserId: String?, aliasId: String?)
suspend fun fetchAssignments()
@@ -20,7 +20,7 @@ interface SdkContext {
class SdkContextImpl(
private val configManager: () -> ConfigManager,
) : SdkContext {
- override fun reevaluateTestMode(appUserId: String?, aliasId: String?) {
+ override suspend fun reevaluateTestMode(appUserId: String?, aliasId: String?) {
configManager().reevaluateTestMode(appUserId = appUserId, aliasId = aliasId)
}
diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt
index 7ca44896f..251f1a9c5 100644
--- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt
+++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt
@@ -295,7 +295,7 @@ class Superwall(
* @param subscriptionStatus The entitlement status of the user.
*/
fun setSubscriptionStatus(subscriptionStatus: SubscriptionStatus) {
- if (dependencyContainer.testModeManager.isTestMode) {
+ if (dependencyContainer.testMode.isTestMode) {
Logger.debug(
LogLevel.warn,
LogScope.superwallCore,
@@ -317,7 +317,7 @@ class Superwall(
* @param entitlements A list of entitlements.
* */
fun setSubscriptionStatus(vararg entitlements: String) {
- if (dependencyContainer.testModeManager.isTestMode) {
+ if (dependencyContainer.testMode.isTestMode) {
Logger.debug(
LogLevel.warn,
LogScope.superwallCore,
@@ -345,7 +345,7 @@ class Superwall(
if (dependencyContainer.makeHasExternalPurchaseController()) {
return
}
- if (dependencyContainer.testModeManager.isTestMode) {
+ if (dependencyContainer.testMode.isTestMode) {
return
}
val webEntitlements = dependencyContainer.entitlements.web
@@ -467,7 +467,7 @@ class Superwall(
val configurationStateListener: Flow
get() =
- dependencyContainer.configManager.configState.asSharedFlow().map {
+ dependencyContainer.configManager.configState.map {
when (it) {
is ConfigState.Retrieved -> ConfigurationStatus.Configured
is ConfigState.Failed -> ConfigurationStatus.Failed
diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt
new file mode 100644
index 000000000..368d8a100
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt
@@ -0,0 +1,39 @@
+package com.superwall.sdk.config
+
+import android.content.Context
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.config.models.ConfigState
+import com.superwall.sdk.config.options.SuperwallOptions
+import com.superwall.sdk.identity.IdentityManager
+import com.superwall.sdk.misc.primitives.BaseContext
+import com.superwall.sdk.models.config.Config
+import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.models.triggers.Trigger
+import com.superwall.sdk.network.SuperwallAPI
+import com.superwall.sdk.network.device.DeviceHelper
+import com.superwall.sdk.paywall.manager.PaywallManager
+import com.superwall.sdk.store.Entitlements
+import com.superwall.sdk.store.StoreManager
+import com.superwall.sdk.store.testmode.TestMode
+import com.superwall.sdk.web.WebPaywallRedeemer
+
+interface ConfigContext : BaseContext {
+ val context: Context
+ val storeManager: StoreManager
+ val entitlements: Entitlements
+ val network: SuperwallAPI
+ val deviceHelper: DeviceHelper
+ val options: SuperwallOptions
+ val paywallManager: PaywallManager
+ val webPaywallRedeemer: () -> WebPaywallRedeemer
+ val factory: ConfigManager.Factory
+ val assignments: Assignments
+ val paywallPreload: PaywallPreload
+ val testMode: TestMode?
+ val identityManager: (() -> IdentityManager)?
+ val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)?
+ val awaitUtilNetwork: suspend () -> Unit
+ val activateTestMode: suspend (config: Config, justActivated: Boolean) -> Unit
+
+ fun setTriggers(triggers: Map)
+}
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 6746fce0e..a454bd30d 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt
@@ -2,11 +2,10 @@ package com.superwall.sdk.config
import android.content.Context
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
-import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.TestModeModal.*
+import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
import com.superwall.sdk.config.models.ConfigState
import com.superwall.sdk.config.models.getConfig
import com.superwall.sdk.config.options.SuperwallOptions
-import com.superwall.sdk.config.options.computedShouldPreload
import com.superwall.sdk.dependencies.DeviceHelperFactory
import com.superwall.sdk.dependencies.DeviceInfoFactory
import com.superwall.sdk.dependencies.HasExternalPurchaseControllerFactory
@@ -14,79 +13,54 @@ import com.superwall.sdk.dependencies.RequestFactory
import com.superwall.sdk.dependencies.RuleAttributesFactory
import com.superwall.sdk.dependencies.StoreTransactionFactory
import com.superwall.sdk.identity.IdentityManager
-import com.superwall.sdk.logger.LogLevel
-import com.superwall.sdk.logger.LogScope
-import com.superwall.sdk.logger.Logger
-import com.superwall.sdk.misc.ActivityProvider
-import com.superwall.sdk.misc.CurrentActivityTracker
-import com.superwall.sdk.misc.Either
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.misc.awaitFirstValidConfig
-import com.superwall.sdk.misc.fold
-import com.superwall.sdk.misc.into
-import com.superwall.sdk.misc.onError
-import com.superwall.sdk.misc.then
+import com.superwall.sdk.misc.primitives.StateActor
import com.superwall.sdk.models.config.Config
-import com.superwall.sdk.models.enrichment.Enrichment
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.network.Network
import com.superwall.sdk.network.SuperwallAPI
import com.superwall.sdk.network.awaitUntilNetworkExists
import com.superwall.sdk.network.device.DeviceHelper
import com.superwall.sdk.paywall.manager.PaywallManager
-import com.superwall.sdk.storage.DisableVerboseEvents
-import com.superwall.sdk.storage.LatestConfig
-import com.superwall.sdk.storage.LatestEnrichment
import com.superwall.sdk.storage.Storage
import com.superwall.sdk.store.Entitlements
import com.superwall.sdk.store.StoreManager
-import com.superwall.sdk.store.abstractions.product.StoreProduct
-import com.superwall.sdk.store.testmode.TestModeManager
-import com.superwall.sdk.store.testmode.TestStoreProduct
-import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
-import com.superwall.sdk.store.testmode.ui.TestModeModal
+import com.superwall.sdk.store.testmode.TestMode
import com.superwall.sdk.web.WebPaywallRedeemer
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeout
-import java.util.concurrent.atomic.AtomicInteger
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
open class ConfigManager(
- private val context: Context,
- private val storeManager: StoreManager,
- private val entitlements: Entitlements,
- private val storage: Storage,
- private val network: SuperwallAPI,
- private val fullNetwork: Network? = null,
- private val deviceHelper: DeviceHelper,
- var options: SuperwallOptions,
- private val paywallManager: PaywallManager,
- private val webPaywallRedeemer: () -> WebPaywallRedeemer,
- private val factory: Factory,
- private val assignments: Assignments,
- private val paywallPreload: PaywallPreload,
+ override val context: Context,
+ override val storeManager: StoreManager,
+ override val entitlements: Entitlements,
+ override val storage: Storage,
+ override val network: SuperwallAPI,
+ override val deviceHelper: DeviceHelper,
+ override var options: SuperwallOptions,
+ override val paywallManager: PaywallManager,
+ override val webPaywallRedeemer: () -> WebPaywallRedeemer,
+ override val factory: Factory,
+ override val assignments: Assignments,
+ override val paywallPreload: PaywallPreload,
private val ioScope: IOScope,
- private val track: suspend (InternalSuperwallEvent) -> Unit,
- private val testModeManager: TestModeManager? = null,
- private val identityManager: (() -> IdentityManager)? = null,
- private val activityProvider: ActivityProvider? = null,
- private val activityTracker: CurrentActivityTracker? = null,
- private val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)? = null,
- private val awaitUtilNetwork: suspend () -> Unit = {
+ override val tracker: suspend (TrackableSuperwallEvent) -> Unit,
+ override val testMode: TestMode? = null,
+ override val identityManager: (() -> IdentityManager)? = null,
+ override val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)? = null,
+ override val awaitUtilNetwork: suspend () -> Unit = {
context.awaitUntilNetworkExists()
},
-) {
+ override val activateTestMode: suspend (Config, Boolean) -> Unit = { _, _ -> },
+ override val actor: StateActor,
+) : ConfigContext {
interface Factory :
RequestFactory,
DeviceInfoFactory,
@@ -95,28 +69,24 @@ open class ConfigManager(
StoreTransactionFactory,
HasExternalPurchaseControllerFactory
- // The configuration of the Superwall dashboard
- internal val configState = MutableStateFlow(ConfigState.None)
+ override val scope: CoroutineScope get() = ioScope
+
+ internal val configState: StateFlow get() = actor.state
- // Convenience variable to access config
val config: Config?
- get() =
- configState.value
- .also {
- if (it is ConfigState.Failed) {
- ioScope.launch {
- fetchConfiguration()
- }
- }
- }.getConfig()
+ get() {
+ val current = actor.state.value
+ if (current is ConfigState.Failed) {
+ effect(ConfigState.Actions.FetchConfig)
+ }
+ return current.getConfig()
+ }
- // A flow that emits just once only when `config` is non-`nil`.
val hasConfig: Flow =
- configState
+ actor.state
.mapNotNull { it.getConfig() }
.take(1)
- // A dictionary of triggers by their event name.
private var _triggersByEventName = mutableMapOf()
var triggersByEventName: Map
get() = _triggersByEventName
@@ -124,490 +94,69 @@ open class ConfigManager(
_triggersByEventName = value.toMutableMap()
}
- // A memory store of assignments that are yet to be confirmed.
+ override fun setTriggers(triggers: Map) {
+ triggersByEventName = triggers
+ }
val unconfirmedAssignments: Map
get() = assignments.unconfirmedAssignments
suspend fun fetchConfiguration() {
- if (configState.value != ConfigState.Retrieving) {
- fetchConfig()
- }
- }
-
- private suspend fun fetchConfig() {
- configState.update { ConfigState.Retrieving }
- val oldConfig = storage.read(LatestConfig)
- val status = entitlements.status.value
- val CACHE_LIMIT = if (status is SubscriptionStatus.Active) 500.milliseconds else 1.seconds
- var isConfigFromCache = false
- var isEnrichmentFromCache = false
-
- // If config is cached, get config from the network but timeout after 300ms
- // and default to the cached version. Then, refresh in the background.
- val configRetryCount: AtomicInteger = AtomicInteger(0)
- var configDuration = 0L
- val configDeferred =
- ioScope.async {
- val start = System.currentTimeMillis()
- (
- if (oldConfig?.featureFlags?.enableConfigRefresh == true) {
- try {
- // If config refresh is enabled, try loading with a timeout
- withTimeout(CACHE_LIMIT) {
- network
- .getConfig {
- // Emit retrying state
- configState.update { ConfigState.Retrying }
- configRetryCount.incrementAndGet()
- awaitUtilNetwork()
- }.into {
- if (it is Either.Failure) {
- isConfigFromCache = true
- Either.Success(oldConfig)
- } else {
- it
- }
- }
- }
- } catch (e: Throwable) {
- e.printStackTrace()
- // If fetching config fails, default to the cached version
- // Note: Only a timeout exception is possible here
- oldConfig?.let {
- isConfigFromCache = true
- Either.Success(it)
- } ?: Either.Failure(e)
- }
- } else {
- // If config refresh is disabled or there is no cache
- // just fetch with a normal retry
- network
- .getConfig {
- configState.update { ConfigState.Retrying }
- configRetryCount.incrementAndGet()
- context.awaitUntilNetworkExists()
- }
- }
- ).also {
- configDuration = System.currentTimeMillis() - start
- }
- }
-
- val enrichmentDeferred =
- ioScope.async {
- val cached = storage.read(LatestEnrichment)
- if (oldConfig?.featureFlags?.enableConfigRefresh == true) {
- // If we have a cached config and refresh was enabled, try loading with
- // a timeout or load from cache
- val res =
- deviceHelper
- .getEnrichment(0, CACHE_LIMIT)
- .then {
- storage.write(LatestEnrichment, it)
- }
- if (res.getSuccess() == null) {
- // Loading timed out, we default to cached version
- cached?.let {
- deviceHelper.setEnrichment(cached)
- isEnrichmentFromCache = true
- Either.Success(it)
- } ?: res
- } else {
- res
- }
- } else {
- // If there's no cached enrichment and config refresh is disabled,
- // try to fetch with 1 sec timeout or fail.
- deviceHelper.getEnrichment(0, 1.seconds)
- }
- }
-
- val attributesDeferred = ioScope.async { factory.makeSessionDeviceAttributes() }
-
- // Await results from both operations
- val (result, enriched) =
- listOf(
- configDeferred,
- enrichmentDeferred,
- ).awaitAll()
- val attributes = attributesDeferred.await()
- ioScope.launch {
- @Suppress("UNCHECKED_CAST")
- track(InternalSuperwallEvent.DeviceAttributes(attributes as HashMap))
- }
- val configResult = result as Either
- val enrichmentResult = enriched as Either
- configResult
- .then {
- ioScope.launch {
- track(
- InternalSuperwallEvent.ConfigRefresh(
- isCached = isConfigFromCache,
- buildId = it.buildId,
- fetchDuration = configDuration,
- retryCount = configRetryCount.get(),
- ),
- )
- }
- }.then(::processConfig)
- .then {
- if (testModeManager?.isTestMode != true) {
- ioScope.launch {
- checkForWebEntitlements()
- }
- }
- }.then {
- if (testModeManager?.isTestMode != true && options.computedShouldPreload(deviceHelper.deviceTier)) {
- val productIds = it.paywalls.flatMap { it.productIds }.toSet()
- try {
- storeManager.products(productIds)
- } catch (e: Throwable) {
- Logger.debug(
- logLevel = LogLevel.error,
- scope = LogScope.productsManager,
- message = "Failed to preload products",
- error = e,
- )
- }
- }
- }.then {
- configState.update { _ -> ConfigState.Retrieved(it) }
- }.then {
- if (isConfigFromCache) {
- ioScope.launch { refreshConfiguration() }
- }
- if (isEnrichmentFromCache || enrichmentResult.getThrowable() != null) {
- ioScope.launch { deviceHelper.getEnrichment(6, 1.seconds) }
- }
- }.fold(
- onSuccess =
- {
- ioScope.launch { preloadPaywalls() }
- },
- onFailure =
- { e ->
- e.printStackTrace()
- configState.update { ConfigState.Failed(e) }
- if (!isConfigFromCache) {
- refreshConfiguration()
- }
- track(InternalSuperwallEvent.ConfigFail(e.message ?: "Unknown error"))
- Logger.debug(
- logLevel = LogLevel.error,
- scope = LogScope.superwallCore,
- message = "Failed to Fetch Configuration",
- error = e,
- )
- },
- )
+ val current = actor.state.value
+ if (current is ConfigState.Retrieving || current is ConfigState.Retrying) return
+ immediate(ConfigState.Actions.FetchConfig)
}
+ // Sync on caller for the mutation; preload follow-up goes through the actor.
fun reset() {
- val config = configState.value.getConfig() ?: return
+ val config = actor.state.value.getConfig() ?: return
assignments.reset()
assignments.choosePaywallVariants(config.triggers)
-
- ioScope.launch { preloadPaywalls() }
+ effect(ConfigState.Actions.PreloadIfEnabled)
}
- /**
- * Re-evaluates test mode with the current identity and config.
- * If test mode was active but the current user no longer qualifies, clears test mode
- * and resets subscription status. If a new user qualifies, activates test mode and
- * shows the modal.
- */
- fun reevaluateTestMode(
- config: Config? = configState.value.getConfig(),
+ suspend fun reevaluateTestMode(
+ config: Config? = null,
appUserId: String? = null,
aliasId: String? = null,
) {
- config ?: return
- val wasTestMode = testModeManager?.isTestMode == true
- testModeManager?.evaluateTestMode(
- config = config,
- bundleId = deviceHelper.bundleId,
- appUserId = appUserId ?: identityManager?.invoke()?.appUserId,
- aliasId = aliasId ?: identityManager?.invoke()?.aliasId,
- testModeBehavior = options.testModeBehavior,
+ // Resolved in body, not as default param — actor reads in defaults trip MockK.
+ val resolvedConfig = config ?: actor.state.value.getConfig() ?: return
+ if (testMode == null) return
+ immediate(
+ ConfigState.Actions.ReevaluateTestMode(
+ config = resolvedConfig,
+ appUserId = appUserId,
+ aliasId = aliasId,
+ ),
)
- val isNowTestMode = testModeManager?.isTestMode == true
- if (wasTestMode && !isNowTestMode) {
- testModeManager?.clearTestModeState()
- setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive)
- } else if (!wasTestMode && isNowTestMode) {
- ioScope.launch {
- fetchTestModeProducts()
- presentTestModeModal(config)
- }
- }
}
suspend fun getAssignments() {
- val config = configState.awaitFirstValidConfig() ?: return
-
- config.triggers.takeUnless { it.isEmpty() }?.let { triggers ->
- try {
- assignments
- .getAssignments(triggers)
- .then {
- ioScope.launch { preloadPaywalls() }
- }.onError {
- Logger.debug(
- logLevel = LogLevel.error,
- scope = LogScope.configManager,
- message = "Error retrieving assignments.",
- error = it,
- )
- }
- } catch (e: Throwable) {
- e.printStackTrace()
- Logger.debug(
- logLevel = LogLevel.error,
- scope = LogScope.configManager,
- message = "Error retrieving assignments.",
- error = e,
- )
- }
- }
+ actor.state.awaitFirstValidConfig()
+ immediate(ConfigState.Actions.GetAssignments)
}
- private fun processConfig(config: Config) {
- storage.write(DisableVerboseEvents, config.featureFlags.disableVerboseEvents)
- if (config.featureFlags.enableConfigRefresh) {
- storage.write(LatestConfig, config)
- }
- triggersByEventName = ConfigLogic.getTriggersByEventName(config.triggers)
- assignments.choosePaywallVariants(config.triggers)
- // Extract entitlements from both products (ProductItem) and productsV3 (CrossplatformProduct)
- ConfigLogic.extractEntitlementsByProductId(config.products).let {
- entitlements.addEntitlementsByProductId(it)
- }
- config.productsV3?.let { productsV3 ->
- ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(productsV3).let {
- entitlements.addEntitlementsByProductId(it)
- }
- }
-
- // Test mode evaluation
- val wasTestMode = testModeManager?.isTestMode == true
- testModeManager?.evaluateTestMode(
- config = config,
- bundleId = deviceHelper.bundleId,
- appUserId = identityManager?.invoke()?.appUserId,
- aliasId = identityManager?.invoke()?.aliasId,
- testModeBehavior = options.testModeBehavior,
- )
- val testModeJustActivated = !wasTestMode && testModeManager?.isTestMode == true
-
- if (testModeManager?.isTestMode == true) {
- // Set a default subscription status immediately so the paywall pipeline
- // doesn't timeout waiting for it while the test mode modal is shown.
- if (testModeJustActivated) {
- val defaultStatus = testModeManager.buildSubscriptionStatus()
- testModeManager.setOverriddenSubscriptionStatus(defaultStatus)
- entitlements.setSubscriptionStatus(defaultStatus)
- }
- ioScope.launch {
- fetchTestModeProducts()
- if (testModeJustActivated) {
- presentTestModeModal(config)
- }
- }
- } else {
- if (wasTestMode) {
- testModeManager?.clearTestModeState()
- setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive)
- }
- ioScope.launch {
- storeManager.loadPurchasedProducts(entitlements.entitlementsByProductId)
- }
- }
+ suspend fun preloadAllPaywalls() {
+ actor.state.awaitFirstValidConfig()
+ immediate(ConfigState.Actions.PreloadAll)
}
-// Preloading Paywalls
-
- // Preloads paywalls.
- private suspend fun preloadPaywalls() {
- if (!options.computedShouldPreload(deviceHelper.deviceTier)) return
- preloadAllPaywalls()
+ suspend fun preloadPaywallsByNames(eventNames: Set) {
+ actor.state.awaitFirstValidConfig()
+ immediate(ConfigState.Actions.PreloadByNames(eventNames))
}
- // Preloads paywalls referenced by triggers.
- suspend fun preloadAllPaywalls() =
- paywallPreload.preloadAllPaywalls(
- configState.awaitFirstValidConfig(),
- context,
- )
-
- // Preloads paywalls referenced by the provided triggers.
- suspend fun preloadPaywallsByNames(eventNames: Set) =
- paywallPreload.preloadPaywallsByNames(
- configState.awaitFirstValidConfig(),
- eventNames,
- )
-
- private suspend fun Either.handleConfigUpdate(
- fetchDuration: Long,
- retryCount: Int,
- ) = then {
- paywallManager.resetPaywallRequestCache()
- val oldConfig = config
- if (oldConfig != null) {
- paywallPreload.removeUnusedPaywallVCsFromCache(oldConfig, it)
- }
- }.then { config ->
- processConfig(config)
- configState.update { ConfigState.Retrieved(config) }
- track(
- InternalSuperwallEvent.ConfigRefresh(
- isCached = false,
- buildId = config.buildId,
- fetchDuration = fetchDuration,
- retryCount = retryCount,
- ),
- )
- }.fold(
- onSuccess = { newConfig ->
- ioScope.launch { preloadPaywalls() }
- },
- onFailure = {
- Logger.debug(
- logLevel = LogLevel.warn,
- scope = LogScope.superwallCore,
- message = "Failed to refresh configuration.",
- info = null,
- error = it,
- )
- },
- )
-
internal suspend fun refreshConfiguration(force: Boolean = false) {
- // Make sure config already exists
- val oldConfig = config ?: return
-
- // Ensure the config refresh feature flag is enabled
- if (!force && !oldConfig.featureFlags.enableConfigRefresh) {
- return
- }
-
- ioScope.launch {
- deviceHelper.getEnrichment(0, 1.seconds)
- }
-
- val retryCount: AtomicInteger = AtomicInteger(0)
- val startTime = System.currentTimeMillis()
- network
- .getConfig {
- retryCount.incrementAndGet()
- context.awaitUntilNetworkExists()
- }.handleConfigUpdate(
- retryCount = retryCount.get(),
- fetchDuration = System.currentTimeMillis() - startTime,
- )
+ if (actor.state.value.getConfig() == null) return
+ immediate(ConfigState.Actions.RefreshConfig(force = force))
}
- suspend fun checkForWebEntitlements() {
- ioScope.launch {
- webPaywallRedeemer().redeem(WebPaywallRedeemer.RedeemType.Existing)
- }
+ internal fun applyRetrievedConfigForTesting(config: Config) {
+ actor.update(ConfigState.Updates.SetRetrieved(config))
}
- private suspend fun fetchTestModeProducts() {
- val net = fullNetwork ?: return
- val manager = testModeManager ?: return
-
- net.getSuperwallProducts().fold(
- onSuccess = { response ->
- val androidProducts =
- response.data.filter { it.platform == SuperwallProductPlatform.ANDROID && it.price != null }
- manager.setProducts(androidProducts)
-
- val productsByFullId =
- androidProducts.associate { superwallProduct ->
- val testProduct = TestStoreProduct(superwallProduct)
- superwallProduct.identifier to StoreProduct(testProduct)
- }
- manager.setTestProducts(productsByFullId)
-
- Logger.debug(
- LogLevel.info,
- LogScope.superwallCore,
- "Test mode: loaded ${androidProducts.size} products",
- )
- },
- onFailure = { error ->
- Logger.debug(
- LogLevel.error,
- LogScope.superwallCore,
- "Test mode: failed to fetch products - ${error.message}",
- )
- },
- )
- }
-
- private suspend fun presentTestModeModal(config: Config) {
- val manager = testModeManager ?: return
- // Prefer the lifecycle-tracked activity (sees the actual foreground activity,
- // e.g. SuperwallPaywallActivity) over the user-provided ActivityProvider
- // (which in Expo/RN may always return the root MainActivity).
- val activity =
- activityTracker?.getCurrentActivity()
- ?: activityProvider?.getCurrentActivity()
- ?: activityTracker?.awaitActivity(10.seconds)
- if (activity == null) {
- Logger.debug(
- LogLevel.warn,
- LogScope.superwallCore,
- "Test mode modal could not be presented: no activity available. Setting default subscription status.",
- )
- with(manager) {
- val status = buildSubscriptionStatus()
- setOverriddenSubscriptionStatus(status)
- entitlements.setSubscriptionStatus(status)
- }
- return
- }
-
- track(InternalSuperwallEvent.TestModeModal(State.Open))
-
- val reason = manager.testModeReason?.description ?: "Test mode activated"
- val allEntitlements =
- config.productsV3
- ?.flatMap { it.entitlements.map { e -> e.id } }
- ?.distinct()
- ?.sorted()
- ?: emptyList()
-
- val dashboardBaseUrl =
- when (options.networkEnvironment) {
- is SuperwallOptions.NetworkEnvironment.Developer -> "https://superwall.dev"
- else -> "https://superwall.com"
- }
-
- val apiKey = deviceHelper.storage.apiKey
- val savedSettings = manager.loadSettings()
-
- val result =
- TestModeModal.show(
- activity = activity,
- reason = reason,
- hasPurchaseController = factory.makeHasExternalPurchaseController(),
- availableEntitlements = allEntitlements,
- apiKey = apiKey,
- dashboardBaseUrl = dashboardBaseUrl,
- savedSettings = savedSettings,
- )
-
- with(manager) {
- setFreeTrialOverride(result.freeTrialOverride)
- setEntitlements(result.entitlements)
- saveSettings()
- val status = buildSubscriptionStatus()
- setOverriddenSubscriptionStatus(status)
- entitlements.setSubscriptionStatus(status)
- }
-
- track(InternalSuperwallEvent.TestModeModal(State.Close))
+ internal fun setConfigStateForTesting(state: ConfigState) {
+ actor.update(ConfigState.Updates.Set(state))
}
}
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 4affe9578..a552819d0 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
@@ -1,8 +1,37 @@
package com.superwall.sdk.config.models
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.config.ConfigContext
+import com.superwall.sdk.config.ConfigLogic
+import com.superwall.sdk.config.options.computedShouldPreload
+import com.superwall.sdk.logger.LogLevel
+import com.superwall.sdk.logger.LogScope
+import com.superwall.sdk.logger.Logger
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.awaitFirstValidConfig
+import com.superwall.sdk.misc.fold
+import com.superwall.sdk.misc.into
+import com.superwall.sdk.misc.onError
+import com.superwall.sdk.misc.primitives.Reducer
+import com.superwall.sdk.misc.primitives.TypedAction
+import com.superwall.sdk.misc.then
+import com.superwall.sdk.misc.thenIf
import com.superwall.sdk.models.config.Config
+import com.superwall.sdk.models.enrichment.Enrichment
+import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.storage.DisableVerboseEvents
+import com.superwall.sdk.storage.LatestConfig
+import com.superwall.sdk.storage.LatestEnrichment
+import com.superwall.sdk.web.WebPaywallRedeemer
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
-internal sealed class ConfigState {
+sealed class ConfigState {
object None : ConfigState()
object Retrieving : ConfigState()
@@ -15,7 +44,360 @@ internal sealed class ConfigState {
data class Failed(
val throwable: Throwable,
+ val retryCount: Int = 0,
) : ConfigState()
+
+ internal sealed class Updates(
+ override val reduce: (ConfigState) -> ConfigState,
+ ) : Reducer {
+ object SetRetrieving : Updates({ Retrieving })
+
+ object SetRetrying : Updates({ Retrying })
+
+ data class SetRetrieved(val config: Config) : Updates({ Retrieved(config) })
+
+ data class SetFailed(
+ val error: Throwable,
+ val retryCount: Int = 0,
+ ) : Updates({ Failed(error, retryCount) })
+
+ data class Set(val state: ConfigState) : Updates({ state })
+ }
+
+ internal sealed class Actions(
+ override val execute: suspend ConfigContext.() -> Unit,
+ ) : TypedAction {
+ object FetchConfig : Actions(exec@{
+ val current = state.value
+ if (current is Retrieving || current is Retrying) return@exec
+
+ // Capture before transitioning out of Failed; Retrieved resets the lineage.
+ val priorRetries = (current as? Failed)?.retryCount ?: 0
+
+ update(Updates.SetRetrieving)
+
+ val oldConfig = storage.read(LatestConfig)
+ val status = entitlements.status.value
+ val cacheLimit =
+ if (status is SubscriptionStatus.Active) 500.milliseconds else 1.seconds
+
+ var isConfigFromCache = false
+ var isEnrichmentFromCache = false
+ val configRetryCount = AtomicInteger(0)
+ var configDuration = 0L
+
+ val configDeferred =
+ scope.async {
+ val start = System.currentTimeMillis()
+ (
+ if (oldConfig?.featureFlags?.enableConfigRefresh == true) {
+ try {
+ withTimeout(cacheLimit) {
+ network
+ .getConfig {
+ update(Updates.SetRetrying)
+ configRetryCount.incrementAndGet()
+ awaitUtilNetwork()
+ }.into {
+ if (it is Either.Failure) {
+ isConfigFromCache = true
+ Either.Success(oldConfig)
+ } else {
+ it
+ }
+ }
+ }
+ } catch (e: Throwable) {
+ e.printStackTrace()
+ oldConfig.let {
+ isConfigFromCache = true
+ Either.Success(it)
+ }
+ }
+ } else {
+ network.getConfig {
+ update(Updates.SetRetrying)
+ configRetryCount.incrementAndGet()
+ awaitUtilNetwork()
+ }
+ }
+ ).also {
+ configDuration = System.currentTimeMillis() - start
+ }
+ }
+
+ val enrichmentDeferred =
+ scope.async {
+ val cached = storage.read(LatestEnrichment)
+ if (oldConfig?.featureFlags?.enableConfigRefresh == true) {
+ val res =
+ deviceHelper
+ .getEnrichment(0, cacheLimit)
+ .then { storage.write(LatestEnrichment, it) }
+ if (res.getSuccess() == null) {
+ cached?.let {
+ deviceHelper.setEnrichment(cached)
+ isEnrichmentFromCache = true
+ Either.Success(it)
+ } ?: res
+ } else {
+ res
+ }
+ } else {
+ deviceHelper.getEnrichment(0, 1.seconds)
+ }
+ }
+
+ val attributesDeferred = scope.async { factory.makeSessionDeviceAttributes() }
+
+ val (configResultAny, enrichmentResultAny) =
+ listOf(configDeferred, enrichmentDeferred).awaitAll()
+ val attributes = attributesDeferred.await()
+ scope.launch {
+ @Suppress("UNCHECKED_CAST")
+ track(InternalSuperwallEvent.DeviceAttributes(attributes as HashMap))
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ val configResult = configResultAny as Either
+
+ @Suppress("UNCHECKED_CAST")
+ val enrichmentResult = enrichmentResultAny as Either
+
+ configResult
+ .then { config ->
+ track(
+ InternalSuperwallEvent.ConfigRefresh(
+ isCached = isConfigFromCache,
+ buildId = config.buildId,
+ fetchDuration = configDuration,
+ retryCount = configRetryCount.get(),
+ ),
+ )
+ }.then { config -> immediate(ApplyConfig(config)) }
+ .thenIf(testMode?.isTestMode != true) {
+ sideEffect {
+ webPaywallRedeemer().redeem(WebPaywallRedeemer.RedeemType.Existing)
+ }
+ }.then { config ->
+ if (testMode?.isTestMode != true &&
+ options.computedShouldPreload(deviceHelper.deviceTier)
+ ) {
+ val productIds = config.paywalls.flatMap { it.productIds }.toSet()
+ try {
+ storeManager.products(productIds)
+ } catch (e: Throwable) {
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.productsManager,
+ message = "Failed to preload products",
+ error = e,
+ )
+ }
+ }
+ config
+ }.then { config ->
+ update(Updates.SetRetrieved(config))
+ }.then {
+ if (isEnrichmentFromCache || enrichmentResult.getThrowable() != null) {
+ scope.launch { deviceHelper.getEnrichment(6, 1.seconds) }
+ }
+ }.fold(
+ onSuccess = {
+ // Preload before refresh — cached boot serves cached paywalls fast.
+ effect(PreloadIfEnabled)
+ if (isConfigFromCache) {
+ effect(RefreshConfig())
+ }
+ },
+ onFailure = { e ->
+ immediate(HandleFetchFailure(e, priorRetries, isConfigFromCache))
+ },
+ )
+ })
+
+ data class HandleFetchFailure(
+ val error: Throwable,
+ val priorRetries: Int,
+ val isConfigFromCache: Boolean,
+ ) : Actions({
+ error.printStackTrace()
+ val newRetries = priorRetries + 1
+ update(Updates.SetFailed(error, retryCount = newRetries))
+ if (!isConfigFromCache && newRetries <= 1) {
+ effect(FetchConfig)
+ }
+ track(InternalSuperwallEvent.ConfigFail(error.message ?: "Unknown error"))
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.superwallCore,
+ message = "Failed to Fetch Configuration",
+ error = error,
+ )
+ })
+
+ data class RefreshConfig(val force: Boolean = false) : Actions(exec@{
+ val oldConfig = state.value.getConfig() ?: return@exec
+ if (!force && !oldConfig.featureFlags.enableConfigRefresh) return@exec
+
+ scope.launch { deviceHelper.getEnrichment(0, 1.seconds) }
+
+ val retryCount = AtomicInteger(0)
+ val startTime = System.currentTimeMillis()
+ val result =
+ network.getConfig {
+ retryCount.incrementAndGet()
+ awaitUtilNetwork()
+ }
+
+ result
+ .then { newConfig ->
+ paywallManager.resetPaywallRequestCache()
+ val previous = state.value.getConfig()
+ if (previous != null) {
+ paywallPreload.removeUnusedPaywallVCsFromCache(previous, newConfig)
+ }
+ newConfig
+ }.then { newConfig ->
+ immediate(ApplyConfig(newConfig))
+ update(Updates.SetRetrieved(newConfig))
+ track(
+ InternalSuperwallEvent.ConfigRefresh(
+ isCached = false,
+ buildId = newConfig.buildId,
+ fetchDuration = System.currentTimeMillis() - startTime,
+ retryCount = retryCount.get(),
+ ),
+ )
+ newConfig
+ }.fold(
+ onSuccess = { effect(PreloadIfEnabled) },
+ onFailure = { e ->
+ Logger.debug(
+ logLevel = LogLevel.warn,
+ scope = LogScope.superwallCore,
+ message = "Failed to refresh configuration.",
+ info = null,
+ error = e,
+ )
+ },
+ )
+ })
+
+ data class ApplyConfig(val config: Config) : Actions({
+ storage.write(DisableVerboseEvents, config.featureFlags.disableVerboseEvents)
+ if (config.featureFlags.enableConfigRefresh) {
+ storage.write(LatestConfig, config)
+ }
+ setTriggers(ConfigLogic.getTriggersByEventName(config.triggers))
+ assignments.choosePaywallVariants(config.triggers)
+
+ ConfigLogic.extractEntitlementsByProductId(config.products).let {
+ entitlements.addEntitlementsByProductId(it)
+ }
+ config.productsV3?.let { productsV3 ->
+ ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(productsV3).let {
+ entitlements.addEntitlementsByProductId(it)
+ }
+ }
+
+ val manager = testMode
+ val wasTestMode = manager?.isTestMode == true
+ manager?.evaluateTestMode(
+ config = config,
+ bundleId = deviceHelper.bundleId,
+ appUserId = identityManager?.invoke()?.appUserId,
+ aliasId = identityManager?.invoke()?.aliasId,
+ testModeBehavior = options.testModeBehavior,
+ )
+ val testModeJustActivated = !wasTestMode && manager?.isTestMode == true
+
+ if (manager?.isTestMode == true) {
+ if (testModeJustActivated) {
+ val defaultStatus = manager.buildSubscriptionStatus()
+ manager.setOverriddenSubscriptionStatus(defaultStatus)
+ entitlements.setSubscriptionStatus(defaultStatus)
+ }
+ scope.launch { activateTestMode(config, testModeJustActivated) }
+ } else {
+ if (wasTestMode) {
+ manager?.clearTestModeState()
+ setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive)
+ }
+ scope.launch {
+ storeManager.loadPurchasedProducts(entitlements.entitlementsByProductId)
+ }
+ }
+ })
+
+ data class ReevaluateTestMode(
+ val config: Config,
+ val appUserId: String?,
+ val aliasId: String?,
+ ) : Actions(exec@{
+ val manager = testMode ?: return@exec
+ val wasTestMode = manager.isTestMode
+ manager.evaluateTestMode(
+ config = config,
+ bundleId = deviceHelper.bundleId,
+ appUserId = appUserId ?: identityManager?.invoke()?.appUserId,
+ aliasId = aliasId ?: identityManager?.invoke()?.aliasId,
+ testModeBehavior = options.testModeBehavior,
+ )
+ val isNowTestMode = manager.isTestMode
+ if (wasTestMode && !isNowTestMode) {
+ manager.clearTestModeState()
+ setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive)
+ } else if (!wasTestMode && isNowTestMode) {
+ scope.launch { activateTestMode(config, true) }
+ }
+ })
+
+ object PreloadIfEnabled : Actions(exec@{
+ if (!options.computedShouldPreload(deviceHelper.deviceTier)) return@exec
+ val config = state.value.getConfig() ?: return@exec
+ paywallPreload.preloadAllPaywalls(config, context)
+ })
+
+ object PreloadAll : Actions(exec@{
+ val config = state.value.getConfig() ?: return@exec
+ paywallPreload.preloadAllPaywalls(config, context)
+ })
+
+ data class PreloadByNames(
+ val eventNames: Set,
+ ) : Actions(exec@{
+ val config = state.value.getConfig() ?: return@exec
+ paywallPreload.preloadPaywallsByNames(config, eventNames)
+ })
+
+ object GetAssignments : Actions(exec@{
+ val config = state.value.getConfig() ?: return@exec
+ config.triggers.takeUnless { it.isEmpty() }?.let { triggers ->
+ try {
+ assignments
+ .getAssignments(triggers)
+ .then { effect(PreloadIfEnabled) }
+ .onError { err ->
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.configManager,
+ message = "Error retrieving assignments.",
+ error = err,
+ )
+ }
+ } catch (e: Throwable) {
+ e.printStackTrace()
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.configManager,
+ message = "Error retrieving assignments.",
+ error = e,
+ )
+ }
+ }
+ })
+ }
}
internal fun ConfigState.getConfig(): Config? =
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 7b21f4508..15ba4c86c 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -123,7 +123,7 @@ import com.superwall.sdk.store.StoreManager
import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager
import com.superwall.sdk.store.abstractions.transactions.GoogleBillingPurchaseTransaction
import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
-import com.superwall.sdk.store.testmode.TestModeManager
+import com.superwall.sdk.store.testmode.TestMode
import com.superwall.sdk.store.testmode.TestModeTransactionHandler
import com.superwall.sdk.store.transactions.TransactionManager
import com.superwall.sdk.utilities.DateUtils
@@ -208,7 +208,7 @@ class DependencyContainer(
internal val customCallbackRegistry: CustomCallbackRegistry
var entitlements: Entitlements
- internal val testModeManager: TestModeManager
+ internal val testMode: TestMode
internal val testModeTransactionHandler: TestModeTransactionHandler
internal lateinit var customerInfoManager: CustomerInfoManager
lateinit var reedemer: WebPaywallRedeemer
@@ -272,10 +272,27 @@ class DependencyContainer(
_apiKey = apiKey
)
entitlements = Entitlements(storage)
- testModeManager = TestModeManager(storage)
+ val options = options ?: SuperwallOptions()
+ testMode =
+ TestMode(
+ storage = storage,
+ getSuperwallProducts = { network.getSuperwallProducts() },
+ entitlements = entitlements,
+ activityProvider = { this.activityProvider },
+ activityTracker = { currentActivityTracker },
+ hasExternalPurchaseController = { makeHasExternalPurchaseController() },
+ apiKey = { storage.apiKey },
+ dashboardBaseUrl = {
+ when (options.networkEnvironment) {
+ is SuperwallOptions.NetworkEnvironment.Developer -> "https://superwall.dev"
+ else -> "https://superwall.com"
+ }
+ },
+ track = { Superwall.instance.track(it) },
+ )
testModeTransactionHandler =
TestModeTransactionHandler(
- testModeManager = testModeManager,
+ testMode = testMode,
activityProvider = activityProvider,
activityTracker = currentActivityTracker,
)
@@ -309,7 +326,7 @@ class DependencyContainer(
customerInfoManager = { customerInfoManager },
)
},
- testModeManager = testModeManager,
+ testMode = testMode,
)
delegateAdapter = SuperwallDelegateAdapter()
@@ -321,7 +338,6 @@ class DependencyContainer(
makeHeaders(debugging, requestId)
},
)
- val options = options ?: SuperwallOptions()
api = Api(networkEnvironment = options.networkEnvironment)
network =
@@ -429,13 +445,22 @@ class DependencyContainer(
configManager = { configManager },
)
+ // Config actor setup — the SequentialActor serializes all state-mutating
+ // actions (fetch, refresh, reset, reevaluate test mode) through a single
+ // FIFO queue, so applying a new config can never race with a variant pick.
+ val configActor =
+ SequentialActor(
+ com.superwall.sdk.config.models.ConfigState.None,
+ ioScope,
+ )
+ // DebugInterceptor.install(configActor, name = "Config")
+
configManager =
ConfigManager(
context = context,
storeManager = storeManager,
storage = storage,
network = network,
- fullNetwork = network,
options = options,
factory = this,
paywallManager = paywallManager,
@@ -443,18 +468,20 @@ class DependencyContainer(
assignments = assignments,
ioScope = ioScope,
paywallPreload = paywallPreload,
- track = {
+ tracker = {
Superwall.instance.track(it)
},
entitlements = entitlements,
webPaywallRedeemer = { reedemer },
- testModeManager = testModeManager,
+ testMode = testMode,
identityManager = { identityManager },
- activityProvider = activityProvider,
- activityTracker = currentActivityTracker,
setSubscriptionStatus = { status ->
entitlements.setSubscriptionStatus(status)
},
+ activateTestMode = { config, justActivated ->
+ testMode.activate(config, justActivated)
+ },
+ actor = configActor,
)
identityManager =
@@ -531,7 +558,7 @@ class DependencyContainer(
storage = storage,
activityProvider,
factory = this,
- testModeManager = testModeManager,
+ testMode = testMode,
testModeTransactionHandler = testModeTransactionHandler,
setSubscriptionStatus = { status ->
entitlements.setSubscriptionStatus(status)
@@ -851,7 +878,7 @@ class DependencyContainer(
locale = deviceHelper.locale,
)
- override fun makeIsSandbox(): Boolean = testModeManager.isTestMode || deviceHelper.isSandbox
+ override fun makeIsSandbox(): Boolean = testMode.isTestMode || deviceHelper.isSandbox
override suspend fun makeSessionDeviceAttributes(): HashMap {
val attributes = deviceHelper.getTemplateDevice().toMutableMap()
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 ec4cd057e..f27880c24 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt
@@ -45,7 +45,7 @@ import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables
import com.superwall.sdk.storage.LocalStorage
import com.superwall.sdk.storage.core_data.CoreDataManager
import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
-import com.superwall.sdk.store.testmode.TestModeManager
+import com.superwall.sdk.store.testmode.TestMode
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
@@ -258,6 +258,6 @@ interface CoreDataManagerFactory {
fun makeCoreDataManager(): CoreDataManager
}
-internal interface TestModeManagerFactory {
- fun makeTestModeManager(): TestModeManager?
+internal interface TestModeFactory {
+ fun makeTestMode(): TestMode?
}
diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt
index 5f0fa7a38..9ca63af5f 100644
--- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt
+++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt
@@ -15,6 +15,5 @@ interface IdentityContext : BaseContext {
val sdkContext: SdkContext
val webPaywallRedeemer: () -> WebPaywallRedeemer
val completeReset: () -> Unit
- val track: suspend (Trackable) -> Unit
val notifyUserChange: ((Map) -> Unit)?
}
diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt
index d5f55dd8a..8d9a2c402 100644
--- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt
@@ -31,7 +31,7 @@ class IdentityManager(
override val completeReset: () -> Unit = {
Superwall.instance.reset(duringIdentify = true)
},
- private val trackEvent: suspend (TrackableSuperwallEvent) -> Unit = {
+ override val tracker: suspend (TrackableSuperwallEvent) -> Unit = {
Superwall.instance.track(it)
},
private val options: () -> SuperwallOptions,
@@ -41,7 +41,6 @@ class IdentityManager(
override val sdkContext: SdkContext,
) : IdentityContext {
override val scope: CoroutineScope get() = ioScope
- override val track: suspend (Trackable) -> Unit = { trackEvent(it as TrackableSuperwallEvent) }
private val identity get() = actor.state.value
diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Either.kt b/superwall/src/main/java/com/superwall/sdk/misc/Either.kt
index 28e327b4c..e5753c563 100644
--- a/superwall/src/main/java/com/superwall/sdk/misc/Either.kt
+++ b/superwall/src/main/java/com/superwall/sdk/misc/Either.kt
@@ -39,6 +39,27 @@ suspend fun Either.then(then: suspend (In) -> Unit):
is Either.Failure -> this
}
+suspend fun Either.thenIf(
+ boolean: Boolean,
+ then: suspend (In) -> Unit
+): Either =
+ when (this) {
+ is Either.Success -> {
+ try {
+ if (boolean) {
+ then(this.value)
+ }
+ this
+ } catch (e: Throwable) {
+ (e as? E)?.let { Either.Failure(it) }
+ ?: Either.Failure(IllegalStateException("Error in then block", e) as E)
+ }
+ }
+
+ is Either.Failure -> this
+ }
+
+
fun Either.map(transform: (In) -> Out): Either =
when (this) {
is Either.Success -> Either.Success(transform(this.value))
@@ -124,4 +145,5 @@ inline fun Either.toResult() =
is Either.Failure -> Result.failure(this.error)
}
-suspend inline fun Either.into(crossinline map: suspend (Either) -> Either): Either = map(this)
+suspend inline fun Either.into(crossinline map: suspend (Either) -> Either): Either =
+ map(this)
diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt
index b9741a035..14c6c1918 100644
--- a/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt
+++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt
@@ -1,7 +1,9 @@
package com.superwall.sdk.misc.primitives
+import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
import com.superwall.sdk.storage.Storable
import com.superwall.sdk.storage.Storage
+import kotlinx.coroutines.launch
/**
* SDK-level actor context — extends [StoreContext] with storage helpers.
@@ -11,6 +13,8 @@ import com.superwall.sdk.storage.Storage
interface BaseContext> : StoreContext {
val storage: Storage
+ val tracker: suspend (TrackableSuperwallEvent) -> Unit
+
/** Persist a value to storage. */
fun persist(
storable: Storable,
@@ -29,4 +33,8 @@ interface BaseContext> : StoreContext {
@Suppress("UNCHECKED_CAST")
storage.delete(storable as Storable)
}
+
+ fun track(event: TrackableSuperwallEvent) {
+ scope.launch { tracker(event) }
+ }
}
diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt
index f49bf08ef..b0c062700 100644
--- a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt
+++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt
@@ -2,6 +2,7 @@ package com.superwall.sdk.misc.primitives
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
/**
* Pure actor context — the minimal contract for action execution.
@@ -46,4 +47,8 @@ interface StoreContext> : StateStore {
) {
actor.immediateUntil(this as Self, action, until)
}
+
+ fun sideEffect(what: suspend () -> Unit){
+ scope.launch { what() }
+ }
}
diff --git a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt
index fcbe5ef70..1b73fc7e0 100644
--- a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt
+++ b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt
@@ -23,12 +23,15 @@ class BaseHostService(
requestId: String,
): Map = factory.makeHeaders(isForDebugging, requestId)
- suspend fun config(requestId: String) =
- get(
- "static_config",
- requestId = requestId,
- queryItems = listOf(URLQueryItem("pk", factory.storage.apiKey)),
- )
+ suspend fun config(
+ requestId: String,
+ isRetryingCallback: (suspend () -> Unit)? = null,
+ ) = get(
+ "static_config",
+ requestId = requestId,
+ queryItems = listOf(URLQueryItem("pk", factory.storage.apiKey)),
+ isRetryingCallback = isRetryingCallback,
+ )
suspend fun assignments() = get("assignments")
diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt
index bb1d55386..2379f5a26 100644
--- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt
+++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt
@@ -60,7 +60,8 @@ open class Network(
return baseHostService
.config(
- requestId,
+ requestId = requestId,
+ isRetryingCallback = isRetryingCallback,
).map { config ->
config.requestId = requestId
config
diff --git a/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt b/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt
index bfb3e54ea..44e47a8d1 100644
--- a/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt
+++ b/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt
@@ -24,6 +24,7 @@ abstract class NetworkService {
isForDebugging: Boolean = false,
requestId: String = UUID.randomUUID().toString(),
retryCount: Int = NetworkConsts.retryCount(),
+ noinline isRetryingCallback: (suspend () -> Unit)? = null,
timeout: Duration? = null
): Either where T : @Serializable Any =
customHttpUrlConnection.request(
@@ -43,6 +44,7 @@ abstract class NetworkService {
)
},
retryCount = retryCount,
+ isRetryingCallback = isRetryingCallback,
)
suspend inline fun post(
diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
index 062623195..f8c0b852a 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
@@ -18,7 +18,7 @@ import com.superwall.sdk.paywall.request.PaywallRequest
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager
import com.superwall.sdk.store.coordinator.ProductsFetcher
-import com.superwall.sdk.store.testmode.TestModeManager
+import com.superwall.sdk.store.testmode.TestMode
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitAll
import java.util.Date
@@ -31,7 +31,7 @@ class StoreManager(
private val track: suspend (InternalSuperwallEvent) -> Unit = {
Superwall.instance.track(it)
},
- var testModeManager: TestModeManager? = null,
+ var testMode: TestMode? = null,
) : ProductsFetcher,
StoreKit {
val receiptManager by lazy(receiptManagerFactory)
@@ -319,7 +319,7 @@ class StoreManager(
override fun getProductFromCache(productId: String): StoreProduct? {
// Check test products first when in test mode
- testModeManager?.let { manager ->
+ testMode?.let { manager ->
if (manager.isTestMode) {
manager.testProductsByFullId[productId]?.let { return it }
}
@@ -328,7 +328,7 @@ class StoreManager(
}
override fun hasCached(productId: String): Boolean {
- testModeManager?.let { manager ->
+ testMode?.let { manager ->
if (manager.isTestMode && manager.testProductsByFullId.containsKey(productId)) {
return true
}
diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeManager.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt
similarity index 63%
rename from superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeManager.kt
rename to superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt
index 35706ebcb..e3a4ed261 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt
@@ -1,25 +1,59 @@
package com.superwall.sdk.store.testmode
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.TestModeModal.State
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
+import com.superwall.sdk.misc.ActivityProvider
+import com.superwall.sdk.misc.CurrentActivityTracker
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.fold
import com.superwall.sdk.models.config.Config
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.network.NetworkError
import com.superwall.sdk.storage.IsTestModeActiveSubscription
import com.superwall.sdk.storage.Storage
import com.superwall.sdk.storage.StoredTestModeSettings
import com.superwall.sdk.storage.TestModeSettings
+import com.superwall.sdk.store.Entitlements
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.testmode.models.SuperwallEntitlementRef
import com.superwall.sdk.store.testmode.models.SuperwallProduct
+import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
+import com.superwall.sdk.store.testmode.models.SuperwallProductsResponse
import com.superwall.sdk.store.testmode.models.TestStoreUserType
import com.superwall.sdk.store.testmode.ui.EntitlementSelection
import com.superwall.sdk.store.testmode.ui.EntitlementStateOption
+import com.superwall.sdk.store.testmode.ui.TestModeModal
+import kotlin.time.Duration.Companion.seconds
-class TestModeManager(
+/**
+ * The single test-mode surface: holds the activation state (products,
+ * entitlement selections, settings persistence) AND runs the activation UI
+ * flow (`activate` → refresh products → present modal).
+ *
+ * Not exactly a "manager" — the UI flow pieces (activity lookup, subscription
+ * products fetch, modal presentation) are injected as thin lambdas so this
+ * class stays testable and config-slice-free.
+ */
+class TestMode(
private val storage: Storage,
private val isTestEnvironment: Boolean = Companion.isTestEnvironment,
+ // Activation UI hooks — all default to no-ops so unit tests exercising
+ // state management can construct `TestMode(storage)` without wiring the
+ // whole UI/network surface.
+ private val getSuperwallProducts: suspend () -> Either = {
+ Either.Failure(NetworkError.Unknown())
+ },
+ private val entitlements: Entitlements? = null,
+ private val activityProvider: () -> ActivityProvider? = { null },
+ private val activityTracker: () -> CurrentActivityTracker? = { null },
+ private val hasExternalPurchaseController: () -> Boolean = { false },
+ private val apiKey: () -> String = { "" },
+ private val dashboardBaseUrl: () -> String = { "" },
+ private val track: suspend (InternalSuperwallEvent) -> Unit = { },
) {
companion object {
val isTestEnvironment: Boolean by lazy {
@@ -248,4 +282,103 @@ class TestModeManager(
fun clearSettings() {
storage.delete(StoredTestModeSettings)
}
+
+ // ---- Activation UI flow ------------------------------------------------
+
+ /**
+ * Refresh the test product catalog and (when [justActivated] is true)
+ * present the test-mode modal. Must be called off the actor queue —
+ * [presentModal] blocks on user interaction.
+ */
+ suspend fun activate(
+ config: Config,
+ justActivated: Boolean,
+ ) {
+ refreshProducts()
+ if (justActivated) {
+ presentModal(config)
+ }
+ }
+
+ private suspend fun refreshProducts() {
+ getSuperwallProducts().fold(
+ onSuccess = { response ->
+ val androidProducts =
+ response.data.filter {
+ it.platform == SuperwallProductPlatform.ANDROID && it.price != null
+ }
+ setProducts(androidProducts)
+
+ val productsByFullId =
+ androidProducts.associate { superwallProduct ->
+ val testProduct = TestStoreProduct(superwallProduct)
+ superwallProduct.identifier to StoreProduct(testProduct)
+ }
+ setTestProducts(productsByFullId)
+
+ Logger.debug(
+ LogLevel.info,
+ LogScope.superwallCore,
+ "Test mode: loaded ${androidProducts.size} products",
+ )
+ },
+ onFailure = { error ->
+ Logger.debug(
+ LogLevel.error,
+ LogScope.superwallCore,
+ "Test mode: failed to fetch products - ${error.message}",
+ )
+ },
+ )
+ }
+
+ private suspend fun presentModal(config: Config) {
+ val activity =
+ activityTracker()?.getCurrentActivity()
+ ?: activityProvider()?.getCurrentActivity()
+ ?: activityTracker()?.awaitActivity(10.seconds)
+ if (activity == null) {
+ Logger.debug(
+ LogLevel.warn,
+ LogScope.superwallCore,
+ "Test mode modal could not be presented: no activity available. Setting default subscription status.",
+ )
+ val status = buildSubscriptionStatus()
+ setOverriddenSubscriptionStatus(status)
+ entitlements?.setSubscriptionStatus(status)
+ return
+ }
+
+ track(InternalSuperwallEvent.TestModeModal(State.Open))
+
+ val reason = testModeReason?.description ?: "Test mode activated"
+ val allEntitlements =
+ config.productsV3
+ ?.flatMap { it.entitlements.map { e -> e.id } }
+ ?.distinct()
+ ?.sorted()
+ ?: emptyList()
+
+ val savedSettings = loadSettings()
+
+ val result =
+ TestModeModal.show(
+ activity = activity,
+ reason = reason,
+ hasPurchaseController = hasExternalPurchaseController(),
+ availableEntitlements = allEntitlements,
+ apiKey = apiKey(),
+ dashboardBaseUrl = dashboardBaseUrl(),
+ savedSettings = savedSettings,
+ )
+
+ setFreeTrialOverride(result.freeTrialOverride)
+ setEntitlements(result.entitlements)
+ saveSettings()
+ val status = buildSubscriptionStatus()
+ setOverriddenSubscriptionStatus(status)
+ entitlements?.setSubscriptionStatus(status)
+
+ track(InternalSuperwallEvent.TestModeModal(State.Close))
+ }
}
diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt
index c8e8fad17..9d75b9ab2 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt
@@ -17,7 +17,7 @@ import com.superwall.sdk.store.testmode.ui.TestModeRestoreDrawer
import com.superwall.sdk.store.transactions.TransactionManager.PurchaseSource
class TestModeTransactionHandler(
- private val testModeManager: TestModeManager,
+ private val testMode: TestMode,
private val activityProvider: ActivityProvider,
private val activityTracker: CurrentActivityTracker? = null,
) {
@@ -37,10 +37,10 @@ class TestModeTransactionHandler(
?: return PurchaseResult.Failed("Activity not found - required for test mode purchase drawer")
val superwallProduct =
- testModeManager.products.find { it.identifier == product.fullIdentifier }
+ testMode.products.find { it.identifier == product.fullIdentifier }
val entitlements = superwallProduct?.entitlements ?: emptyList()
- val hasFreeTrial = testModeManager.shouldShowFreeTrial(product.hasFreeTrial)
+ val hasFreeTrial = testMode.shouldShowFreeTrial(product.hasFreeTrial)
Logger.debug(
LogLevel.debug,
@@ -61,9 +61,9 @@ class TestModeTransactionHandler(
return when (result) {
is PurchaseSimulationResult.Purchased -> {
- testModeManager.fakePurchase(entitlements)
- val status = testModeManager.buildSubscriptionStatus()
- testModeManager.setOverriddenSubscriptionStatus(status)
+ testMode.fakePurchase(entitlements)
+ val status = testMode.buildSubscriptionStatus()
+ testMode.setOverriddenSubscriptionStatus(status)
PurchaseResult.Purchased()
}
is PurchaseSimulationResult.Abandoned -> {
@@ -80,7 +80,7 @@ class TestModeTransactionHandler(
getForegroundActivity()
?: return RestorationResult.Failed(Throwable("Activity not found"))
- val allEntitlements = testModeManager.allEntitlements()
+ val allEntitlements = testMode.allEntitlements()
Logger.debug(
LogLevel.debug,
@@ -92,14 +92,14 @@ class TestModeTransactionHandler(
TestModeRestoreDrawer.show(
activity = activity,
availableEntitlements = allEntitlements.toList(),
- currentSelections = testModeManager.testEntitlementSelections,
+ currentSelections = testMode.testEntitlementSelections,
)
return when (result) {
is RestoreSimulationResult.Restored -> {
- testModeManager.setEntitlements(result.selectedEntitlements)
- val status = testModeManager.buildSubscriptionStatus()
- testModeManager.setOverriddenSubscriptionStatus(status)
+ testMode.setEntitlements(result.selectedEntitlements)
+ val status = testMode.buildSubscriptionStatus()
+ testMode.setOverriddenSubscriptionStatus(status)
RestorationResult.Restored()
}
is RestoreSimulationResult.Cancelled -> {
@@ -108,7 +108,7 @@ class TestModeTransactionHandler(
}
}
- fun findSuperwallProductForId(productId: String): SuperwallProduct? = testModeManager.products.find { it.identifier == productId }
+ fun findSuperwallProductForId(productId: String): SuperwallProduct? = testMode.products.find { it.identifier == productId }
fun entitlementsForProduct(productId: String): Set {
val superwallProduct = findSuperwallProductForId(productId) ?: return emptySet()
diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
index 44cc16a87..17fa423bf 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
@@ -47,7 +47,7 @@ import com.superwall.sdk.store.StoreManager
import com.superwall.sdk.store.abstractions.product.RawStoreProduct
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
-import com.superwall.sdk.store.testmode.TestModeManager
+import com.superwall.sdk.store.testmode.TestMode
import com.superwall.sdk.store.testmode.TestModeTransactionHandler
import com.superwall.sdk.web.openRestoreOnWeb
import kotlinx.coroutines.flow.asSharedFlow
@@ -79,7 +79,7 @@ class TransactionManager(
private val refreshReceipt: () -> Unit,
private val updateState: (cacheKey: String, update: PaywallViewState.Updates) -> Unit,
private val notifyOfTransactionComplete: suspend (paywallCacheKey: String, trialEndDate: Long?, productId: String) -> Unit,
- private val testModeManager: TestModeManager? = null,
+ private val testMode: TestMode? = null,
private val testModeTransactionHandler: TestModeTransactionHandler? = null,
private val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)? = null,
) {
@@ -299,13 +299,13 @@ class TransactionManager(
}
// Test mode intercept: simulate purchase without real billing
- if (testModeManager?.isTestMode == true && testModeTransactionHandler != null) {
+ if (testMode?.isTestMode == true && testModeTransactionHandler != null) {
prepareToPurchase(product, purchaseSource)
val result = testModeTransactionHandler.handlePurchase(product, purchaseSource)
when (result) {
is PurchaseResult.Purchased -> {
// In test mode, set subscription status directly (no real receipt to verify)
- val status = testModeManager.buildSubscriptionStatus()
+ val status = testMode.buildSubscriptionStatus()
setSubscriptionStatus?.invoke(status)
trackTransactionDidSucceed(null, product, purchaseSource, product.hasFreeTrial)
if (shouldDismiss && purchaseSource is PurchaseSource.Internal) {
@@ -831,10 +831,10 @@ class TransactionManager(
log(message = "Attempting Restore")
// Test mode intercept: simulate restore without real billing
- if (testModeManager?.isTestMode == true && testModeTransactionHandler != null) {
+ if (testMode?.isTestMode == true && testModeTransactionHandler != null) {
val result = testModeTransactionHandler.handleRestore()
if (result is RestorationResult.Restored) {
- val status = testModeManager.buildSubscriptionStatus()
+ val status = testMode.buildSubscriptionStatus()
setSubscriptionStatus?.invoke(status)
}
return result
diff --git a/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt b/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt
new file mode 100644
index 000000000..ded8598d6
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt
@@ -0,0 +1,76 @@
+package com.superwall.sdk
+
+import com.superwall.sdk.config.ConfigManager
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+/**
+ * Smoke tests for [SdkContextImpl] — the cross-slice bridge used by the identity
+ * actor to reach into [ConfigManager]. Thin delegates, but since they're the only
+ * bridge between the two actors, a missing forward would silently break identity
+ * flows in production.
+ */
+class SdkContextImplTest {
+ @Test
+ fun `reevaluateTestMode forwards appUserId and aliasId to ConfigManager`() =
+ runTest {
+ val manager =
+ mockk(relaxed = true) {
+ coEvery { reevaluateTestMode(any(), any(), any()) } just Runs
+ }
+ val ctx = SdkContextImpl(configManager = { manager })
+
+ ctx.reevaluateTestMode(appUserId = "user-1", aliasId = "alias-1")
+
+ coVerify(exactly = 1) {
+ manager.reevaluateTestMode(
+ config = any(),
+ appUserId = "user-1",
+ aliasId = "alias-1",
+ )
+ }
+ }
+
+ @Test
+ fun `fetchAssignments delegates to ConfigManager_getAssignments`() =
+ runTest {
+ val manager =
+ mockk {
+ coEvery { getAssignments() } just Runs
+ }
+ val ctx = SdkContextImpl(configManager = { manager })
+
+ ctx.fetchAssignments()
+
+ coVerify(exactly = 1) { manager.getAssignments() }
+ }
+
+ @Test
+ fun `configManager factory is invoked lazily so teardown-reconfigure swaps are observable`() =
+ runTest {
+ val first =
+ mockk(relaxed = true) {
+ coEvery { reevaluateTestMode(any(), any(), any()) } just Runs
+ }
+ val second =
+ mockk(relaxed = true) {
+ coEvery { reevaluateTestMode(any(), any(), any()) } just Runs
+ }
+ var current: ConfigManager = first
+ val ctx = SdkContextImpl(configManager = { current })
+
+ ctx.reevaluateTestMode(null, null)
+ coVerify(exactly = 1) { first.reevaluateTestMode(any(), any(), any()) }
+
+ current = second
+ ctx.reevaluateTestMode(null, null)
+ coVerify(exactly = 1) { second.reevaluateTestMode(any(), any(), any()) }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt
new file mode 100644
index 000000000..aa2bb4943
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt
@@ -0,0 +1,1588 @@
+package com.superwall.sdk.config
+
+import android.content.Context
+import com.superwall.sdk.analytics.Tier
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
+import com.superwall.sdk.config.models.ConfigState
+import com.superwall.sdk.config.options.SuperwallOptions
+import com.superwall.sdk.identity.IdentityManager
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.IOScope
+import com.superwall.sdk.misc.primitives.SequentialActor
+import com.superwall.sdk.models.config.Config
+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.network.NetworkError
+import com.superwall.sdk.network.SuperwallAPI
+import com.superwall.sdk.network.device.DeviceHelper
+import com.superwall.sdk.paywall.manager.PaywallManager
+import com.superwall.sdk.storage.LatestConfig
+import com.superwall.sdk.storage.LatestEnrichment
+import com.superwall.sdk.storage.Storage
+import com.superwall.sdk.store.Entitlements
+import com.superwall.sdk.store.StoreManager
+import com.superwall.sdk.store.testmode.TestMode
+import com.superwall.sdk.store.testmode.TestModeBehavior
+import com.superwall.sdk.web.WebPaywallRedeemer
+import com.superwall.sdk.models.assignment.Assignment
+import com.superwall.sdk.storage.DisableVerboseEvents
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.time.Duration.Companion.seconds
+
+class ConfigManagerTest {
+ private fun config(
+ buildId: String = "stub",
+ triggers: Set = emptySet(),
+ enableRefresh: Boolean = false,
+ ): Config =
+ Config.stub().copy(
+ buildId = buildId,
+ triggers = triggers,
+ rawFeatureFlags =
+ if (enableRefresh) {
+ listOf(RawFeatureFlag("enable_config_refresh_v2", true))
+ } else {
+ emptyList()
+ },
+ )
+
+ private data class Setup(
+ val manager: ConfigManagerForTest,
+ val network: SuperwallAPI,
+ val storage: Storage,
+ val preload: PaywallPreload,
+ val storeManager: StoreManager,
+ val webRedeemer: WebPaywallRedeemer,
+ val deviceHelper: DeviceHelper,
+ val testMode: TestMode?,
+ val tracked: CopyOnWriteArrayList,
+ val statuses: MutableList,
+ val activateCalls: AtomicInteger,
+ )
+
+ @Suppress("LongParameterList")
+ private fun setup(
+ scope: CoroutineScope,
+ cachedConfig: Config? = null,
+ cachedEnrichment: Enrichment? = null,
+ networkConfig: Either = Either.Success(Config.stub()),
+ networkConfigAnswer: (suspend (suspend () -> Unit) -> Either)? = null,
+ deviceTier: Tier = Tier.MID,
+ shouldPreload: Boolean = false,
+ preloadDeviceOverrides: Map = emptyMap(),
+ testModeBehavior: TestModeBehavior = TestModeBehavior.AUTOMATIC,
+ injectedTestMode: TestMode? = null,
+ assignments: Assignments = mockk(relaxed = true),
+ identityManager: IdentityManager? = null,
+ storeManagerOverride: StoreManager? = null,
+ entitlementsOverride: Entitlements? = null,
+ ): Setup {
+ val context = mockk(relaxed = true)
+ val storage =
+ mockk(relaxed = true) {
+ every { read(LatestConfig) } returns cachedConfig
+ every { read(LatestEnrichment) } returns cachedEnrichment
+ every { write(any(), any()) } just Runs
+ }
+ val network =
+ mockk {
+ if (networkConfigAnswer != null) {
+ coEvery { getConfig(any()) } coAnswers {
+ networkConfigAnswer.invoke(firstArg())
+ }
+ } else {
+ coEvery { getConfig(any()) } returns networkConfig
+ }
+ coEvery { getEnrichment(any(), any(), any()) } returns
+ Either.Success(Enrichment.stub())
+ }
+ val deviceHelper =
+ mockk(relaxed = true) {
+ every { appVersion } returns "1.0"
+ every { locale } returns "en-US"
+ every { this@mockk.deviceTier } returns deviceTier
+ every { bundleId } returns "com.test"
+ every { setEnrichment(any()) } just Runs
+ coEvery { getTemplateDevice() } returns emptyMap()
+ coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub())
+ }
+ val storeManager =
+ storeManagerOverride
+ ?: mockk(relaxed = true) {
+ coEvery { products(any()) } returns emptySet()
+ coEvery { loadPurchasedProducts(any()) } just Runs
+ }
+ val preload =
+ mockk {
+ coEvery { preloadAllPaywalls(any(), any()) } just Runs
+ coEvery { preloadPaywallsByNames(any(), any()) } just Runs
+ coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
+ }
+ val paywallManager = mockk(relaxed = true)
+ val webRedeemer = mockk(relaxed = true)
+ val factory =
+ mockk(relaxed = true) {
+ coEvery { makeSessionDeviceAttributes() } returns HashMap()
+ every { makeHasExternalPurchaseController() } returns false
+ }
+ val entitlements =
+ entitlementsOverride ?: mockk(relaxed = true).also {
+ every { it.status } returns
+ kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown)
+ every { it.entitlementsByProductId } returns emptyMap()
+ }
+
+ val tracked = CopyOnWriteArrayList()
+ val statuses = mutableListOf()
+ val activateCalls = AtomicInteger(0)
+
+ val options =
+ SuperwallOptions().apply {
+ paywalls.shouldPreload = shouldPreload
+ paywalls.preloadDeviceOverrides = preloadDeviceOverrides
+ this.testModeBehavior = testModeBehavior
+ }
+
+ val manager =
+ ConfigManagerForTest(
+ context = context,
+ storage = storage,
+ network = network,
+ deviceHelper = deviceHelper,
+ paywallManager = paywallManager,
+ storeManager = storeManager,
+ preload = preload,
+ webRedeemer = webRedeemer,
+ factory = factory,
+ entitlements = entitlements,
+ assignments = assignments,
+ options = options,
+ ioScope = scope,
+ testMode = injectedTestMode,
+ tracker = { tracked.add(it) },
+ setSubscriptionStatus = { statuses.add(it) },
+ activateTestMode = { _, justActivated ->
+ if (justActivated) activateCalls.incrementAndGet()
+ },
+ identityManager = identityManager?.let { im -> { im } },
+ )
+ return Setup(
+ manager,
+ network,
+ storage,
+ preload,
+ storeManager,
+ webRedeemer,
+ deviceHelper,
+ injectedTestMode,
+ tracked,
+ statuses,
+ activateCalls,
+ )
+ }
+
+ @Test
+ fun `autoRetryCount resets after a successful apply`() =
+ runTest(timeout = 30.seconds) {
+ val calls = AtomicInteger(0)
+ val s =
+ setup(
+ backgroundScope,
+ networkConfigAnswer = {
+ when (calls.incrementAndGet()) {
+ 1, 2 -> Either.Failure(NetworkError.Unknown())
+ 3 -> Either.Success(Config.stub())
+ else -> Either.Failure(NetworkError.Unknown())
+ }
+ },
+ )
+
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+ assertEquals("Cold-start = initial + 1 retry", 2, calls.get())
+ assertTrue(s.manager.configState.value is ConfigState.Failed)
+
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+ assertEquals(3, calls.get())
+ assertTrue(s.manager.configState.value is ConfigState.Retrieved)
+
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+ assertEquals("Counter must reset on Retrieved — fresh budget", 5, calls.get())
+ }
+
+ @Test
+ fun `reevaluateTestMode deactivates when user no longer qualifies`() =
+ runTest(timeout = 30.seconds) {
+ val storageForTm = mockk(relaxed = true)
+ val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false))
+ testMode.evaluateTestMode(
+ Config.stub(),
+ "com.test",
+ null,
+ null,
+ testModeBehavior = TestModeBehavior.ALWAYS,
+ )
+ assertTrue(testMode.isTestMode)
+
+ val s = setup(backgroundScope, injectedTestMode = testMode)
+ s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "no-match")
+
+ assertFalse(testMode.isTestMode)
+ verify(atLeast = 1) { testMode.clearTestModeState() }
+ assertTrue(
+ "Expected SubscriptionStatus.Inactive on deactivation",
+ s.statuses.any { it is SubscriptionStatus.Inactive },
+ )
+ }
+
+ @Test
+ fun `reevaluateTestMode activates when user now qualifies`() =
+ runTest(timeout = 30.seconds) {
+ val storageForTm = mockk(relaxed = true)
+ val testMode = TestMode(storage = storageForTm, isTestEnvironment = false)
+ assertFalse(testMode.isTestMode)
+
+ val s =
+ setup(
+ backgroundScope,
+ testModeBehavior = TestModeBehavior.ALWAYS,
+ injectedTestMode = testMode,
+ )
+ s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "anyone")
+ advanceUntilIdle()
+
+ assertTrue(testMode.isTestMode)
+ assertEquals("activateTestMode lambda must fire once", 1, s.activateCalls.get())
+ }
+
+ @Test
+ fun `reevaluateTestMode is noop when state unchanged`() =
+ runTest(timeout = 30.seconds) {
+ val storageForTm = mockk(relaxed = true)
+ val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false))
+ assertFalse(testMode.isTestMode)
+
+ val s =
+ setup(
+ backgroundScope,
+ testModeBehavior = TestModeBehavior.NEVER,
+ injectedTestMode = testMode,
+ )
+ s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "anyone")
+ advanceUntilIdle()
+
+ assertFalse(testMode.isTestMode)
+ verify(exactly = 0) { testMode.clearTestModeState() }
+ assertTrue("No subscription status published on no-op", s.statuses.isEmpty())
+ assertEquals("activateTestMode must not fire on no-op", 0, s.activateCalls.get())
+ }
+
+ // Both reevaluateTestMode and ApplyConfig mutate TestMode.state. They
+ // must be serialized through the config actor — never overlap.
+ @Test
+ fun `reevaluateTestMode and ApplyConfig do not overlap on TestMode state`() =
+ runTest(timeout = 30.seconds) {
+ val storage = mockk(relaxed = true)
+ val inFlight = AtomicInteger(0)
+ val maxOverlap = AtomicInteger(0)
+ val testMode = spyk(TestMode(storage = storage, isTestEnvironment = false))
+ // Track in-flight evaluateTestMode calls. If serialization holds,
+ // maxOverlap stays at 1.
+ every {
+ testMode.evaluateTestMode(any(), any(), any(), any(), any())
+ } answers {
+ val n = inFlight.incrementAndGet()
+ maxOverlap.updateAndGet { kotlin.math.max(it, n) }
+ Thread.sleep(20) // simulate non-trivial work
+ inFlight.decrementAndGet()
+ callOriginal()
+ }
+
+ val s = setup(
+ backgroundScope,
+ injectedTestMode = testMode,
+ testModeBehavior = TestModeBehavior.ALWAYS,
+ )
+
+ // Race: drive ApplyConfig (via fetchConfiguration) and
+ // reevaluateTestMode in parallel.
+ val a = launch { s.manager.fetchConfiguration() }
+ val b = launch { s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "u") }
+ val c = launch { s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "v") }
+ a.join(); b.join(); c.join()
+ advanceUntilIdle()
+
+ assertEquals(
+ "evaluateTestMode must never overlap with itself when serialized through the actor — saw ${maxOverlap.get()} concurrent",
+ 1,
+ maxOverlap.get(),
+ )
+ }
+
+ @Test
+ fun `fetchConfig in test mode skips web entitlements and product preload`() =
+ runTest(timeout = 30.seconds) {
+ val storageForTm = mockk(relaxed = true)
+ val testMode = TestMode(storage = storageForTm, isTestEnvironment = false)
+ val s =
+ setup(
+ backgroundScope,
+ shouldPreload = true,
+ testModeBehavior = TestModeBehavior.ALWAYS,
+ injectedTestMode = testMode,
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ assertTrue(testMode.isTestMode)
+ coVerify(exactly = 0) { s.storeManager.products(any()) }
+ coVerify(exactly = 0) { s.webRedeemer.redeem(any()) }
+ }
+
+ @Test
+ fun `refreshConfig runs full applyConfig fanout`() =
+ runTest(timeout = 30.seconds) {
+ val initial = config(buildId = "initial", triggers = setOf(Trigger.stub().copy(eventName = "trigger_a")), enableRefresh = true)
+ val refreshed = config(buildId = "refreshed", triggers = setOf(Trigger.stub().copy(eventName = "trigger_b")), enableRefresh = true)
+ val getCalls = AtomicInteger(0)
+ val storageForTm = mockk(relaxed = true)
+ val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false))
+
+ val s =
+ setup(
+ backgroundScope,
+ networkConfigAnswer = {
+ Either.Success(if (getCalls.incrementAndGet() == 1) initial else refreshed)
+ },
+ injectedTestMode = testMode,
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "initial" }
+ advanceUntilIdle()
+ assertTrue(s.manager.triggersByEventName.containsKey("trigger_a"))
+
+ s.manager.refreshConfiguration(force = true)
+ s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "refreshed" }
+ advanceUntilIdle()
+
+ assertTrue(s.manager.triggersByEventName.containsKey("trigger_b"))
+ assertFalse(s.manager.triggersByEventName.containsKey("trigger_a"))
+ verify(atLeast = 2) { testMode.evaluateTestMode(any(), any(), any(), any(), any()) }
+ verify { s.storage.write(LatestConfig, refreshed) }
+ }
+
+ @Test
+ fun `preloadAllPaywalls bypasses shouldPreload gate`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope, shouldPreload = false)
+ s.manager.applyRetrievedConfigForTesting(Config.stub())
+
+ s.manager.preloadAllPaywalls()
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) { s.preload.preloadAllPaywalls(any(), any()) }
+ }
+
+ @Test
+ fun `preloadPaywallsByNames bypasses shouldPreload gate`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope, shouldPreload = false)
+ s.manager.applyRetrievedConfigForTesting(Config.stub())
+
+ s.manager.preloadPaywallsByNames(setOf("evt"))
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) { s.preload.preloadPaywallsByNames(any(), eq(setOf("evt"))) }
+ }
+
+ @Test
+ fun `tracking emits ConfigRefresh isCached false and DeviceAttributes on fresh fetch`() =
+ runTest(timeout = 30.seconds) {
+ val s =
+ setup(
+ backgroundScope,
+ networkConfig = Either.Success(Config.stub().copy(buildId = "fresh-id")),
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ val refresh = s.tracked.filterIsInstance()
+ assertTrue("Expected ConfigRefresh event, got ${s.tracked}", refresh.isNotEmpty())
+ assertFalse("Fresh fetch must mark isCached=false", refresh.last().isCached)
+ assertEquals("fresh-id", refresh.last().buildId)
+
+ val deviceAttrs = s.tracked.filterIsInstance()
+ assertTrue("Expected at least one DeviceAttributes event", deviceAttrs.isNotEmpty())
+ }
+
+ @Test
+ fun `tracking emits ConfigRefresh isCached true on cached path`() =
+ runTest(timeout = 30.seconds) {
+ val cached = config(buildId = "cached-id", enableRefresh = true)
+ val s =
+ setup(
+ backgroundScope,
+ cachedConfig = cached,
+ cachedEnrichment = Enrichment.stub(),
+ // network failure on cached-refresh path → FetchConfig's
+ // `.into { if (Failure) Success(oldConfig) }` falls back to cache.
+ networkConfig = Either.Failure(NetworkError.Unknown()),
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "cached-id" }
+ advanceUntilIdle()
+
+ val refresh = s.tracked.filterIsInstance()
+ assertTrue(
+ "Cached path must publish ConfigRefresh with isCached=true",
+ refresh.any { it.isCached && it.buildId == "cached-id" },
+ )
+ }
+
+ @Test
+ fun `tracking emits ConfigFail on failure without cache`() =
+ runTest(timeout = 30.seconds) {
+ val s =
+ setup(
+ backgroundScope,
+ networkConfig = Either.Failure(NetworkError.Unknown()),
+ )
+
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+
+ val fails = s.tracked.filterIsInstance()
+ assertTrue("Expected at least one ConfigFail, got ${s.tracked}", fails.isNotEmpty())
+ }
+
+ @Test
+ fun `fetchConfig skips when already in Retrying state`() =
+ runTest(timeout = 30.seconds) {
+ val calls = AtomicInteger(0)
+ val s =
+ setup(
+ backgroundScope,
+ networkConfigAnswer = { retryCb ->
+ calls.incrementAndGet()
+ retryCb() // flip to Retrying
+ delay(800)
+ Either.Success(Config.stub())
+ },
+ )
+
+ val first = launch { s.manager.fetchConfiguration() }
+ s.manager.configState.first { it is ConfigState.Retrying }
+ s.manager.fetchConfiguration() // must early-return
+ first.join()
+
+ assertEquals("Expected exactly one network.getConfig", 1, calls.get())
+ }
+
+ @Test
+ fun `enrichment success writes LatestEnrichment on cached boot`() =
+ runTest(timeout = 30.seconds) {
+ val freshEnrichment = Enrichment.stub()
+ val cached = config(enableRefresh = true)
+ val s =
+ setup(
+ backgroundScope,
+ cachedConfig = cached,
+ cachedEnrichment = null,
+ )
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ verify(atLeast = 1) { s.storage.write(LatestEnrichment, freshEnrichment) }
+ }
+
+ @Test
+ fun `applyConfig populates entitlements from products and productsV3`() =
+ runTest(timeout = 30.seconds) {
+ val productMap = mapOf("p1" to setOf())
+ val crossplatformMap = mapOf("p2" to setOf())
+ io.mockk.mockkObject(ConfigLogic)
+ try {
+ every { ConfigLogic.extractEntitlementsByProductId(any()) } returns productMap
+ every { ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(any()) } returns crossplatformMap
+ every { ConfigLogic.getTriggersByEventName(any()) } returns emptyMap()
+
+ val entitlements = mockk(relaxed = true).also {
+ every { it.status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown)
+ every { it.entitlementsByProductId } returns emptyMap()
+ }
+ val configWithV3 =
+ Config.stub().copy(productsV3 = listOf(mockk(relaxed = true)))
+ val s =
+ setup(
+ backgroundScope,
+ networkConfig = Either.Success(configWithV3),
+ entitlementsOverride = entitlements,
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ verify(exactly = 1) { entitlements.addEntitlementsByProductId(productMap) }
+ verify(exactly = 1) { entitlements.addEntitlementsByProductId(crossplatformMap) }
+ } finally {
+ io.mockk.unmockkObject(ConfigLogic)
+ }
+ }
+
+ @Test
+ fun `applyConfig skips crossplatform entitlements when productsV3 is null`() =
+ runTest(timeout = 30.seconds) {
+ io.mockk.mockkObject(ConfigLogic)
+ try {
+ val productMap = mapOf("p1" to setOf())
+ every { ConfigLogic.extractEntitlementsByProductId(any()) } returns productMap
+ every { ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(any()) } returns emptyMap()
+ every { ConfigLogic.getTriggersByEventName(any()) } returns emptyMap()
+
+ val entitlements = mockk(relaxed = true).also {
+ every { it.status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown)
+ every { it.entitlementsByProductId } returns emptyMap()
+ }
+ val s =
+ setup(
+ backgroundScope,
+ networkConfig = Either.Success(Config.stub().copy(productsV3 = null)),
+ entitlementsOverride = entitlements,
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ verify(exactly = 1) { entitlements.addEntitlementsByProductId(productMap) }
+ verify(exactly = 0) {
+ ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(any())
+ }
+ } finally {
+ io.mockk.unmockkObject(ConfigLogic)
+ }
+ }
+
+ @Test
+ fun `applyConfig falls back to identityManager for appUserId and aliasId`() =
+ runTest(timeout = 30.seconds) {
+ val identity =
+ mockk(relaxed = true) {
+ every { appUserId } returns "from-identity"
+ every { aliasId } returns "alias-from-identity"
+ }
+ val storageForTm = mockk(relaxed = true)
+ val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false))
+ val s =
+ setup(
+ backgroundScope,
+ injectedTestMode = testMode,
+ identityManager = identity,
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ verify(atLeast = 1) {
+ testMode.evaluateTestMode(
+ config = any(),
+ bundleId = "com.test",
+ appUserId = "from-identity",
+ aliasId = "alias-from-identity",
+ testModeBehavior = any(),
+ )
+ }
+ }
+
+ @Test
+ fun `reevaluateTestMode falls back to identityManager when ids omitted`() =
+ runTest(timeout = 30.seconds) {
+ val identity =
+ mockk(relaxed = true) {
+ every { appUserId } returns "from-identity"
+ every { aliasId } returns "alias-from-identity"
+ }
+ val storageForTm = mockk(relaxed = true)
+ val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false))
+ val s =
+ setup(
+ backgroundScope,
+ testModeBehavior = TestModeBehavior.AUTOMATIC,
+ injectedTestMode = testMode,
+ identityManager = identity,
+ )
+
+ s.manager.reevaluateTestMode(config = Config.stub())
+ advanceUntilIdle()
+
+ verify(atLeast = 1) {
+ testMode.evaluateTestMode(
+ config = any(),
+ bundleId = "com.test",
+ appUserId = "from-identity",
+ aliasId = "alias-from-identity",
+ testModeBehavior = any(),
+ )
+ }
+ }
+
+ @Test
+ fun `fetchConfig completes when storeManager_products throws`() =
+ runTest(timeout = 30.seconds) {
+ val storeManager =
+ mockk(relaxed = true) {
+ coEvery { products(any()) } throws RuntimeException("billing exploded")
+ coEvery { loadPurchasedProducts(any()) } just Runs
+ }
+ val s =
+ setup(
+ backgroundScope,
+ networkConfig = Either.Success(Config.stub().copy(buildId = "ok")),
+ shouldPreload = true, // forces the products() call
+ storeManagerOverride = storeManager,
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ assertEquals(
+ "ok",
+ (s.manager.configState.value as ConfigState.Retrieved).config.buildId,
+ )
+ coVerify(atLeast = 1) { storeManager.products(any()) }
+ }
+
+ @Test
+ fun `tier override false suppresses preload even when shouldPreload is true`() =
+ runTest(timeout = 30.seconds) {
+ val s =
+ setup(
+ backgroundScope,
+ deviceTier = Tier.LOW,
+ shouldPreload = true,
+ preloadDeviceOverrides = mapOf(Tier.LOW to false),
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) }
+ }
+
+ @Test
+ fun `tier override true forces preload even when shouldPreload is false`() =
+ runTest(timeout = 30.seconds) {
+ val s =
+ setup(
+ backgroundScope,
+ deviceTier = Tier.LOW,
+ shouldPreload = false,
+ preloadDeviceOverrides = mapOf(Tier.LOW to true),
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any()) }
+ }
+
+ @Test
+ fun `getAssignments success triggers PreloadIfEnabled`() =
+ runTest(timeout = 30.seconds) {
+ val configWithTriggers =
+ Config.stub().copy(triggers = setOf(Trigger.stub().copy(eventName = "evt")))
+ val assignments =
+ mockk(relaxed = true) {
+ coEvery { getAssignments(any()) } returns Either.Success(emptyList())
+ }
+ val s =
+ setup(
+ backgroundScope,
+ shouldPreload = true,
+ assignments = assignments,
+ )
+ 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.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()) }
+ }
+
+ @Test
+ fun `getAssignments suspends until config is Retrieved`() =
+ runTest(timeout = 30.seconds) {
+ val assignments = mockk(relaxed = true) {
+ coEvery { getAssignments(any()) } returns Either.Success(emptyList())
+ }
+ val s = setup(backgroundScope, assignments = assignments)
+
+ val job = launch { s.manager.getAssignments() }
+ delay(50)
+ assertTrue("getAssignments should still be suspended", job.isActive)
+ coVerify(exactly = 0) { assignments.getAssignments(any()) }
+
+ s.manager.applyRetrievedConfigForTesting(
+ Config.stub().copy(triggers = setOf(Trigger.stub().copy(eventName = "e"))),
+ )
+ job.join()
+ advanceUntilIdle()
+
+ coVerify(atLeast = 1) { assignments.getAssignments(any()) }
+ }
+
+ @Test
+ fun `getAssignments with no triggers does not hit the network`() =
+ runTest(timeout = 30.seconds) {
+ val assignments = mockk(relaxed = true)
+ val s = setup(backgroundScope, assignments = assignments)
+ s.manager.applyRetrievedConfigForTesting(Config.stub().copy(triggers = emptySet()))
+
+ s.manager.getAssignments()
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { assignments.getAssignments(any()) }
+ }
+
+ @Test
+ fun `getAssignments network error is swallowed and state stays Retrieved`() =
+ runTest(timeout = 30.seconds) {
+ val assignments = mockk(relaxed = true) {
+ coEvery { getAssignments(any()) } returns Either.Failure(NetworkError.Unknown())
+ }
+ val s = setup(backgroundScope, assignments = assignments)
+ s.manager.applyRetrievedConfigForTesting(
+ Config.stub().copy(triggers = setOf(Trigger.stub().copy(eventName = "e"))),
+ )
+
+ s.manager.getAssignments()
+ advanceUntilIdle()
+
+ assertTrue(s.manager.configState.value is ConfigState.Retrieved)
+ }
+
+ @Test
+ fun `refreshConfiguration without retrieved config does not hit network`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ s.manager.refreshConfiguration()
+ advanceUntilIdle()
+ coVerify(exactly = 0) { s.network.getConfig(any()) }
+ }
+
+ @Test
+ fun `refreshConfiguration with flag disabled and force false does not hit network`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ s.manager.applyRetrievedConfigForTesting(Config.stub()) // no enableConfigRefresh flag
+ io.mockk.clearMocks(s.network, answers = false)
+ coEvery { s.network.getConfig(any()) } returns Either.Success(Config.stub())
+
+ s.manager.refreshConfiguration(force = false)
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { s.network.getConfig(any()) }
+ }
+
+ @Test
+ fun `refreshConfiguration force true ignores disabled flag`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(
+ backgroundScope,
+ networkConfig = Either.Success(Config.stub().copy(buildId = "forced")),
+ )
+ s.manager.applyRetrievedConfigForTesting(Config.stub())
+ io.mockk.clearMocks(s.network, answers = false)
+ coEvery { s.network.getConfig(any()) } returns Either.Success(Config.stub().copy(buildId = "forced"))
+ coEvery { s.network.getEnrichment(any(), any(), any()) } returns Either.Success(Enrichment.stub())
+
+ s.manager.refreshConfiguration(force = true)
+ advanceUntilIdle()
+
+ coVerify(atLeast = 1) { s.network.getConfig(any()) }
+ }
+
+ @Test
+ fun `refreshConfiguration is noop when state is Retrieving or None`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+
+ s.manager.setConfigStateForTesting(ConfigState.Retrieving)
+ s.manager.refreshConfiguration(force = false)
+ advanceUntilIdle()
+ coVerify(exactly = 0) { s.network.getConfig(any()) }
+
+ s.manager.setConfigStateForTesting(ConfigState.None)
+ s.manager.refreshConfiguration(force = false)
+ advanceUntilIdle()
+ coVerify(exactly = 0) { s.network.getConfig(any()) }
+ }
+
+ @Test
+ fun `refreshConfiguration success resets paywall request cache and removes unused`() =
+ runTest(timeout = 30.seconds) {
+ val oldConfig = config(buildId = "old", enableRefresh = true)
+ val newConfig = config(buildId = "new", enableRefresh = true)
+ val paywallManager = mockk(relaxed = true)
+ val preload = mockk(relaxed = true) {
+ coEvery { preloadAllPaywalls(any(), any()) } just Runs
+ coEvery { preloadPaywallsByNames(any(), any()) } just Runs
+ coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
+ }
+ val s = setup(backgroundScope)
+ val mgr = ConfigManagerForTest(
+ context = mockk(relaxed = true),
+ storage = s.storage,
+ network = mockk {
+ coEvery { getConfig(any()) } returns Either.Success(newConfig)
+ coEvery { getEnrichment(any(), any(), any()) } returns Either.Success(Enrichment.stub())
+ },
+ deviceHelper = s.deviceHelper,
+ paywallManager = paywallManager,
+ storeManager = s.storeManager,
+ preload = preload,
+ webRedeemer = s.webRedeemer,
+ factory = mockk(relaxed = true) {
+ coEvery { makeSessionDeviceAttributes() } returns HashMap()
+ },
+ entitlements = mockk(relaxed = true) {
+ every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown)
+ every { entitlementsByProductId } returns emptyMap()
+ },
+ assignments = mockk(relaxed = true),
+ options = SuperwallOptions().apply { paywalls.shouldPreload = false },
+ ioScope = backgroundScope,
+ testMode = null,
+ tracker = {},
+ setSubscriptionStatus = null,
+ activateTestMode = { _, _ -> },
+ )
+ mgr.applyRetrievedConfigForTesting(oldConfig)
+
+ mgr.refreshConfiguration()
+ advanceUntilIdle()
+
+ verify(atLeast = 1) { paywallManager.resetPaywallRequestCache() }
+ coVerify(atLeast = 1) { preload.removeUnusedPaywallVCsFromCache(oldConfig, newConfig) }
+ }
+
+ @Test
+ fun `refreshConfig failure preserves Retrieved state`() =
+ runTest(timeout = 30.seconds) {
+ val oldConfig = config(buildId = "old", enableRefresh = true)
+ val s = setup(
+ backgroundScope,
+ networkConfig = Either.Failure(NetworkError.Unknown()),
+ )
+ s.manager.applyRetrievedConfigForTesting(oldConfig)
+
+ s.manager.refreshConfiguration(force = true)
+ advanceUntilIdle()
+
+ assertTrue(s.manager.configState.value is ConfigState.Retrieved)
+ assertEquals("old", s.manager.config?.buildId)
+ }
+
+ @Test
+ fun `reset without config does not preload`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ s.manager.reset()
+ advanceUntilIdle()
+ coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) }
+ }
+
+ @Test
+ fun `reset with config rebuilds assignments synchronously`() =
+ runTest(timeout = 30.seconds) {
+ val assignments = mockk(relaxed = true)
+ val s = setup(backgroundScope, assignments = assignments)
+ s.manager.applyRetrievedConfigForTesting(Config.stub())
+ io.mockk.clearMocks(assignments, answers = false)
+ io.mockk.justRun { assignments.reset() }
+ io.mockk.justRun { assignments.choosePaywallVariants(any()) }
+
+ s.manager.reset()
+ verify(exactly = 1) { assignments.reset() }
+ verify(exactly = 1) { assignments.choosePaywallVariants(any()) }
+ }
+
+ @Test
+ fun `preloadAllPaywalls suspends until config is Retrieved`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ val job = launch { s.manager.preloadAllPaywalls() }
+ delay(50)
+ coVerify(exactly = 0) { s.preload.preloadAllPaywalls(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()) }
+ }
+
+ @Test
+ fun `preloadPaywallsByNames suspends until config is Retrieved`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ val names = setOf("evt")
+ val job = launch { s.manager.preloadPaywallsByNames(names) }
+ delay(50)
+ coVerify(exactly = 0) { s.preload.preloadPaywallsByNames(any(), any()) }
+
+ val cfg = Config.stub().copy(buildId = "preload-named")
+ s.manager.applyRetrievedConfigForTesting(cfg)
+ job.join()
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) { s.preload.preloadPaywallsByNames(eq(cfg), eq(names)) }
+ }
+
+ @Test
+ fun `fetchConfiguration updates trigger cache and persists feature flags`() =
+ runTest(timeout = 30.seconds) {
+ val cfg = Config.stub().copy(
+ triggers = setOf(Trigger.stub().copy(eventName = "evt_a")),
+ rawFeatureFlags = listOf(
+ RawFeatureFlag("enable_config_refresh_v2", true),
+ RawFeatureFlag("disable_verbose_events", true),
+ ),
+ )
+ val s = setup(backgroundScope, networkConfig = Either.Success(cfg))
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ assertEquals(setOf("evt_a"), s.manager.triggersByEventName.keys)
+ verify { s.storage.write(DisableVerboseEvents, true) }
+ verify { s.storage.write(LatestConfig, cfg) }
+ }
+
+ @Test
+ fun `fetchConfiguration loads purchased products when not in test mode`() =
+ runTest(timeout = 30.seconds) {
+ val storeManager = mockk(relaxed = true) {
+ coEvery { products(any()) } returns emptySet()
+ coEvery { loadPurchasedProducts(any()) } just Runs
+ }
+ val s = setup(backgroundScope, storeManagerOverride = storeManager)
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ coVerify(atLeast = 1) { storeManager.loadPurchasedProducts(any()) }
+ }
+
+ @Test
+ fun `fetchConfiguration redeems existing web entitlements when not in test mode`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ coVerify(atLeast = 1) { s.webRedeemer.redeem(WebPaywallRedeemer.RedeemType.Existing) }
+ }
+
+ @Test
+ fun `fetchConfiguration preloads products when preloading enabled`() =
+ runTest(timeout = 30.seconds) {
+ val cfg = Config.stub().copy(
+ paywalls = listOf(
+ com.superwall.sdk.models.paywall.Paywall.stub().copy(productIds = listOf("a", "b")),
+ com.superwall.sdk.models.paywall.Paywall.stub().copy(productIds = listOf("b", "c")),
+ ),
+ )
+ val storeManager = mockk(relaxed = true) {
+ coEvery { products(any()) } returns emptySet()
+ coEvery { loadPurchasedProducts(any()) } just Runs
+ }
+ val s = setup(
+ backgroundScope,
+ networkConfig = Either.Success(cfg),
+ shouldPreload = true,
+ storeManagerOverride = storeManager,
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) {
+ storeManager.products(match { it == setOf("a", "b", "c") })
+ }
+ }
+
+ @Test
+ fun `fetchConfiguration emits Retrieving then Failed without cache`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(
+ backgroundScope,
+ networkConfig = Either.Failure(NetworkError.Unknown()),
+ )
+ val states = CopyOnWriteArrayList()
+ val collector = CoroutineScope(Dispatchers.Unconfined).launch {
+ s.manager.configState.collect { states.add(it) }
+ }
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+ collector.cancel()
+
+ assertTrue("Expected Retrieving in lineage, got $states", states.any { it is ConfigState.Retrieving })
+ assertTrue("Expected last state Failed, got ${states.last()}", states.last() is ConfigState.Failed)
+ }
+
+ @Test
+ fun `cached config wins when network getConfig returns Failure`() =
+ runTest(timeout = 30.seconds) {
+ val cached = config(buildId = "cached", enableRefresh = true)
+ val s = setup(
+ backgroundScope,
+ cachedConfig = cached,
+ cachedEnrichment = Enrichment.stub(),
+ networkConfig = Either.Failure(NetworkError.Unknown()),
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ assertEquals("cached", s.manager.config?.buildId)
+ }
+
+ @Test
+ fun `quick network success returns fresh config`() =
+ runTest(timeout = 30.seconds) {
+ val fresh = Config.stub().copy(buildId = "fresh")
+ val s = setup(backgroundScope, networkConfig = Either.Success(fresh))
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ assertEquals("fresh", s.manager.config?.buildId)
+ }
+
+ @Test
+ fun `cached path with delayed network falls back to cache`() =
+ runTest(timeout = 30.seconds) {
+ val cached = config(buildId = "cached", enableRefresh = true)
+ val s = setup(
+ backgroundScope,
+ cachedConfig = cached,
+ cachedEnrichment = Enrichment.stub(),
+ networkConfig = Either.Failure(NetworkError.Unknown()),
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "cached" }
+ advanceUntilIdle()
+
+ assertEquals("cached", s.manager.config?.buildId)
+ }
+
+ @Test
+ fun `network retry callback transitions state to Retrying`() =
+ runTest(timeout = 30.seconds) {
+ val retries = AtomicInteger(0)
+ val s = setup(
+ backgroundScope,
+ networkConfigAnswer = { cb ->
+ cb()
+ cb()
+ retries.set(2)
+ Either.Success(Config.stub())
+ },
+ )
+ val seen = CopyOnWriteArrayList()
+ val collector = CoroutineScope(Dispatchers.Unconfined).launch {
+ s.manager.configState.collect { seen.add(it) }
+ }
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+ collector.cancel()
+
+ assertEquals(2, retries.get())
+ assertTrue("Expected Retrying in $seen", seen.any { it is ConfigState.Retrying })
+ }
+
+ @Test
+ fun `cached config success preloads before refresh`() =
+ runTest(timeout = 30.seconds) {
+ val cached = config(buildId = "cached", enableRefresh = true)
+ val fresh = config(buildId = "fresh", enableRefresh = true)
+ val getCalls = AtomicInteger(0)
+ val s = setup(
+ backgroundScope,
+ cachedConfig = cached,
+ cachedEnrichment = Enrichment.stub(),
+ shouldPreload = true,
+ networkConfigAnswer = {
+ val n = getCalls.incrementAndGet()
+ if (n == 1) Either.Failure(NetworkError.Unknown()) // cached fallback wins
+ else Either.Success(fresh)
+ },
+ )
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "fresh" }
+ advanceUntilIdle()
+
+ coVerifyOrder {
+ s.preload.preloadAllPaywalls(any(), any())
+ s.network.getConfig(any())
+ }
+ assertTrue(getCalls.get() >= 2)
+ }
+
+ @Test
+ fun `concurrent fetchConfiguration calls dedup while Retrieving`() =
+ runTest(timeout = 30.seconds) {
+ val calls = AtomicInteger(0)
+ val s = setup(
+ backgroundScope,
+ networkConfigAnswer = {
+ calls.incrementAndGet()
+ delay(300)
+ Either.Success(Config.stub())
+ },
+ )
+
+ val first = launch { s.manager.fetchConfiguration() }
+ s.manager.configState.first { it is ConfigState.Retrieving }
+ s.manager.fetchConfiguration() // must early-return
+ first.join()
+
+ assertEquals(1, calls.get())
+ }
+
+ @Test
+ fun `reevaluateTestMode flips state synchronously`() =
+ runTest(timeout = 30.seconds) {
+ val storage = mockk(relaxed = true)
+ val testMode = TestMode(storage = storage, isTestEnvironment = false)
+ val s = setup(
+ backgroundScope,
+ injectedTestMode = testMode,
+ testModeBehavior = TestModeBehavior.ALWAYS,
+ )
+ assertFalse(testMode.isTestMode)
+
+ s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "u")
+ assertTrue(testMode.isTestMode)
+ }
+
+ @Test
+ fun `applyConfig side effects happen before Retrieved`() =
+ runTest(timeout = 30.seconds) {
+ val cfg = Config.stub().copy(
+ triggers = setOf(Trigger.stub().copy(eventName = "evt")),
+ rawFeatureFlags = listOf(RawFeatureFlag("disable_verbose_events", true)),
+ )
+ val s = setup(backgroundScope, networkConfig = Either.Success(cfg))
+
+ val triggersAtRetrieved = mutableListOf()
+ val collector = launch {
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ triggersAtRetrieved.addAll(s.manager.triggersByEventName.keys)
+ }
+ s.manager.fetchConfiguration()
+ collector.join()
+
+ assertTrue(triggersAtRetrieved.contains("evt"))
+ verify { s.storage.write(DisableVerboseEvents, true) }
+ }
+
+ @Test
+ fun `applyConfig skips LatestConfig write when refresh flag off`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope, networkConfig = Either.Success(Config.stub()))
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ verify(exactly = 0) { s.storage.write(LatestConfig, any()) }
+ verify { s.storage.write(DisableVerboseEvents, any()) }
+ }
+
+ @Test
+ fun `applyConfig with null testMode loads purchased products`() =
+ runTest(timeout = 30.seconds) {
+ val storeManager = mockk(relaxed = true) {
+ coEvery { loadPurchasedProducts(any()) } just Runs
+ coEvery { products(any()) } returns emptySet()
+ }
+ val s = setup(backgroundScope, injectedTestMode = null, storeManagerOverride = storeManager)
+
+ s.manager.fetchConfiguration()
+ s.manager.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ coVerify(atLeast = 1) { storeManager.loadPurchasedProducts(any()) }
+ }
+
+ @Test
+ fun `applyConfig testMode just-activated publishes subscription status`() =
+ runTest(timeout = 30.seconds) {
+ val storage = mockk(relaxed = true)
+ val testMode = TestMode(storage = storage, isTestEnvironment = false)
+ val s = setup(
+ backgroundScope,
+ injectedTestMode = testMode,
+ testModeBehavior = TestModeBehavior.ALWAYS,
+ )
+ assertFalse(testMode.isTestMode)
+
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+
+ assertTrue(testMode.isTestMode)
+ assertTrue(testMode.overriddenSubscriptionStatus != null)
+ }
+
+ @Test
+ fun `applyConfig deactivates testMode when user no longer qualifies`() =
+ runTest(timeout = 30.seconds) {
+ val storage = mockk(relaxed = true)
+ val testMode = spyk(TestMode(storage = storage, isTestEnvironment = false))
+ testMode.evaluateTestMode(
+ Config.stub(), "com.app", null, null,
+ testModeBehavior = TestModeBehavior.ALWAYS,
+ )
+ assertTrue(testMode.isTestMode)
+
+ val s = setup(
+ backgroundScope,
+ injectedTestMode = testMode,
+ testModeBehavior = TestModeBehavior.AUTOMATIC,
+ )
+
+ s.manager.fetchConfiguration()
+ advanceUntilIdle()
+
+ assertFalse(testMode.isTestMode)
+ verify(atLeast = 1) { testMode.clearTestModeState() }
+ }
+
+ @Test
+ fun `enrichment failure with cached fallback uses cache and schedules retry`() =
+ runTest(timeout = 30.seconds) {
+ val cachedEnrichment = Enrichment.stub()
+ val cached = config(enableRefresh = true)
+ val helper = mockk(relaxed = true) {
+ every { appVersion } returns "1.0"
+ every { locale } returns "en-US"
+ every { deviceTier } returns Tier.MID
+ every { bundleId } returns "com.test"
+ every { setEnrichment(any()) } just Runs
+ coEvery { getTemplateDevice() } returns emptyMap()
+ coEvery { getEnrichment(any(), any()) } returns Either.Failure(NetworkError.Unknown())
+ }
+ val s = setup(
+ backgroundScope,
+ cachedConfig = cached,
+ cachedEnrichment = cachedEnrichment,
+ )
+ val mgr = ConfigManagerForTest(
+ context = mockk(relaxed = true),
+ storage = s.storage,
+ network = s.network,
+ deviceHelper = helper,
+ paywallManager = mockk(relaxed = true),
+ storeManager = s.storeManager,
+ preload = s.preload,
+ webRedeemer = s.webRedeemer,
+ factory = mockk(relaxed = true) {
+ coEvery { makeSessionDeviceAttributes() } returns HashMap()
+ },
+ entitlements = mockk(relaxed = true) {
+ every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown)
+ every { entitlementsByProductId } returns emptyMap()
+ },
+ assignments = mockk(relaxed = true),
+ options = SuperwallOptions(),
+ ioScope = backgroundScope,
+ testMode = null,
+ tracker = {},
+ setSubscriptionStatus = null,
+ activateTestMode = { _, _ -> },
+ )
+
+ mgr.fetchConfiguration()
+ mgr.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ verify { helper.setEnrichment(cachedEnrichment) }
+ coVerify(atLeast = 1) { helper.getEnrichment(6, 1.seconds) }
+ }
+
+ @Test
+ fun `enrichment failure with no cache still reaches Retrieved`() =
+ runTest(timeout = 30.seconds) {
+ val helper = mockk(relaxed = true) {
+ every { appVersion } returns "1.0"
+ every { locale } returns "en-US"
+ every { deviceTier } returns Tier.MID
+ every { bundleId } returns "com.test"
+ every { setEnrichment(any()) } just Runs
+ coEvery { getTemplateDevice() } returns emptyMap()
+ coEvery { getEnrichment(any(), any()) } returns Either.Failure(NetworkError.Unknown())
+ }
+ val s = setup(backgroundScope)
+ val mgr = ConfigManagerForTest(
+ context = mockk(relaxed = true),
+ storage = s.storage,
+ network = s.network,
+ deviceHelper = helper,
+ paywallManager = mockk(relaxed = true),
+ storeManager = s.storeManager,
+ preload = s.preload,
+ webRedeemer = s.webRedeemer,
+ factory = mockk(relaxed = true) {
+ coEvery { makeSessionDeviceAttributes() } returns HashMap()
+ },
+ entitlements = mockk(relaxed = true) {
+ every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown)
+ every { entitlementsByProductId } returns emptyMap()
+ },
+ assignments = mockk(relaxed = true),
+ options = SuperwallOptions(),
+ ioScope = backgroundScope,
+ testMode = null,
+ tracker = {},
+ setSubscriptionStatus = null,
+ activateTestMode = { _, _ -> },
+ )
+
+ mgr.fetchConfiguration()
+ mgr.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ coVerify(atLeast = 1) { helper.getEnrichment(6, 1.seconds) }
+ }
+
+ @Test
+ fun `cached path retry callback invokes awaitUtilNetwork`() =
+ runTest(timeout = 30.seconds) {
+ val cached = config(enableRefresh = true)
+ val awaitCalls = AtomicInteger(0)
+ val network = mockk {
+ coEvery { getConfig(any()) } coAnswers {
+ val cb = firstArg Unit>()
+ cb()
+ Either.Success(cached)
+ }
+ coEvery { getEnrichment(any(), any(), any()) } returns Either.Success(Enrichment.stub())
+ }
+ val storage = mockk(relaxed = true) {
+ every { read(LatestConfig) } returns cached
+ every { read(LatestEnrichment) } returns null
+ every { write(any(), any()) } just Runs
+ }
+ val mgr = ConfigManagerForTest(
+ context = mockk(relaxed = true),
+ storage = storage,
+ network = network,
+ deviceHelper = mockk(relaxed = true) {
+ every { appVersion } returns "1.0"
+ every { locale } returns "en-US"
+ every { deviceTier } returns Tier.MID
+ every { bundleId } returns "com.test"
+ every { setEnrichment(any()) } just Runs
+ coEvery { getTemplateDevice() } returns emptyMap()
+ coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub())
+ },
+ paywallManager = mockk(relaxed = true),
+ storeManager = mockk(relaxed = true) {
+ coEvery { products(any()) } returns emptySet()
+ coEvery { loadPurchasedProducts(any()) } just Runs
+ },
+ preload = mockk(relaxed = true) {
+ coEvery { preloadAllPaywalls(any(), any()) } just Runs
+ coEvery { preloadPaywallsByNames(any(), any()) } just Runs
+ coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs
+ },
+ webRedeemer = mockk(relaxed = true),
+ factory = mockk(relaxed = true) {
+ coEvery { makeSessionDeviceAttributes() } returns HashMap()
+ },
+ entitlements = mockk(relaxed = true) {
+ every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown)
+ every { entitlementsByProductId } returns emptyMap()
+ },
+ assignments = mockk(relaxed = true),
+ options = SuperwallOptions(),
+ ioScope = backgroundScope,
+ testMode = null,
+ tracker = {},
+ setSubscriptionStatus = null,
+ activateTestMode = { _, _ -> },
+ awaitUtilNetwork = { awaitCalls.incrementAndGet() },
+ )
+
+ mgr.fetchConfiguration()
+ mgr.configState.first { it is ConfigState.Retrieved }
+ advanceUntilIdle()
+
+ assertTrue("awaitUtilNetwork must fire on retry callback, got ${awaitCalls.get()}", awaitCalls.get() >= 1)
+ }
+
+ @Test
+ fun `config getter on Failed returns null and dispatches a refetch`() =
+ runTest(timeout = 30.seconds) {
+ val calls = AtomicInteger(0)
+ val s = setup(
+ backgroundScope,
+ networkConfigAnswer = {
+ val n = calls.incrementAndGet()
+ if (n == 1) Either.Failure(NetworkError.Unknown())
+ else Either.Success(Config.stub())
+ },
+ )
+ s.manager.setConfigStateForTesting(ConfigState.Failed(Exception("boom")))
+ assertEquals(null, s.manager.config)
+ advanceUntilIdle()
+ assertTrue("Expected getter to trigger a refetch, calls=${calls.get()}", calls.get() >= 1)
+ }
+
+ @Test
+ fun `config getter on Retrieved does not dispatch fetch`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ s.manager.applyRetrievedConfigForTesting(Config.stub())
+ io.mockk.clearMocks(s.network, answers = false)
+ coEvery { s.network.getConfig(any()) } returns Either.Success(Config.stub())
+
+ repeat(5) { s.manager.config }
+ advanceUntilIdle()
+ coVerify(exactly = 0) { s.network.getConfig(any()) }
+ }
+
+ @Test
+ fun `hasConfig emits when config is set`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ val expected = Config.stub().copy(buildId = "has-config")
+ val emitted = launch {
+ assertEquals(expected.buildId, s.manager.hasConfig.first().buildId)
+ }
+ s.manager.applyRetrievedConfigForTesting(expected)
+ advanceUntilIdle()
+ emitted.join()
+ }
+
+ @Test
+ fun `hasConfig emits exactly once`() =
+ runTest(timeout = 30.seconds) {
+ val s = setup(backgroundScope)
+ val emissions = mutableListOf()
+ val collector = launch {
+ s.manager.hasConfig.collect { emissions.add(it) }
+ }
+ s.manager.applyRetrievedConfigForTesting(Config.stub().copy(buildId = "first"))
+ advanceUntilIdle()
+ s.manager.setConfigStateForTesting(ConfigState.None)
+ advanceUntilIdle()
+ s.manager.applyRetrievedConfigForTesting(Config.stub().copy(buildId = "second"))
+ advanceUntilIdle()
+ collector.cancel()
+
+ assertEquals(1, emissions.size)
+ assertEquals("first", emissions.single().buildId)
+ }
+}
+
+/**
+ * Test-only ConfigManager subclass that hardwires the actor and exposes the
+ * test-only `applyRetrievedConfigForTesting` helper.
+ */
+internal class ConfigManagerForTest(
+ context: Context,
+ storage: Storage,
+ network: SuperwallAPI,
+ deviceHelper: DeviceHelper,
+ paywallManager: PaywallManager,
+ storeManager: StoreManager,
+ preload: PaywallPreload,
+ webRedeemer: WebPaywallRedeemer,
+ factory: ConfigManager.Factory,
+ entitlements: Entitlements,
+ assignments: Assignments,
+ options: SuperwallOptions,
+ ioScope: CoroutineScope,
+ testMode: TestMode?,
+ tracker: suspend (TrackableSuperwallEvent) -> Unit,
+ setSubscriptionStatus: ((SubscriptionStatus) -> Unit)?,
+ activateTestMode: suspend (Config, Boolean) -> Unit,
+ identityManager: (() -> IdentityManager)? = null,
+ awaitUtilNetwork: suspend () -> Unit = {},
+) : ConfigManager(
+ context = context,
+ storeManager = storeManager,
+ entitlements = entitlements,
+ storage = storage,
+ network = network,
+ deviceHelper = deviceHelper,
+ options = options,
+ paywallManager = paywallManager,
+ webPaywallRedeemer = { webRedeemer },
+ factory = factory,
+ assignments = assignments,
+ paywallPreload = preload,
+ ioScope = IOScope(Dispatchers.Unconfined),
+ tracker = tracker,
+ testMode = testMode,
+ identityManager = identityManager,
+ setSubscriptionStatus = setSubscriptionStatus,
+ awaitUtilNetwork = awaitUtilNetwork,
+ activateTestMode = activateTestMode,
+ actor = SequentialActor(ConfigState.None, CoroutineScope(Dispatchers.Unconfined)),
+ )
diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigStateReducerTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigStateReducerTest.kt
new file mode 100644
index 000000000..8ca97c1a0
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigStateReducerTest.kt
@@ -0,0 +1,93 @@
+package com.superwall.sdk.config
+
+import com.superwall.sdk.config.models.ConfigState
+import com.superwall.sdk.models.config.Config
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Pure unit tests for [ConfigState.Updates]. Reducers are `(ConfigState) -> ConfigState`
+ * with no side effects, so they can be exercised without an actor, scope, or context.
+ *
+ * These guard the trivial state shape rather than behavior — if someone adds a field
+ * to ConfigState or subtly changes the phase model, they'll fail fast here instead of
+ * waiting for an integration test to catch it.
+ */
+class ConfigStateReducerTest {
+ private val stubConfig = Config.stub()
+ private val stubError = RuntimeException("boom")
+
+ @Test
+ fun `SetRetrieving replaces any prior state with Retrieving`() {
+ val inputs: List =
+ listOf(
+ ConfigState.None,
+ ConfigState.Retrieving,
+ ConfigState.Retrying,
+ ConfigState.Retrieved(stubConfig),
+ ConfigState.Failed(stubError),
+ )
+ inputs.forEach { input ->
+ val out = ConfigState.Updates.SetRetrieving.reduce(input)
+ assertSame("SetRetrieving from $input", ConfigState.Retrieving, out)
+ }
+ }
+
+ @Test
+ fun `SetRetrying replaces any prior state with Retrying`() {
+ val inputs: List =
+ listOf(
+ ConfigState.None,
+ ConfigState.Retrieving,
+ ConfigState.Retrying,
+ ConfigState.Retrieved(stubConfig),
+ ConfigState.Failed(stubError),
+ )
+ inputs.forEach { input ->
+ val out = ConfigState.Updates.SetRetrying.reduce(input)
+ assertSame("SetRetrying from $input", ConfigState.Retrying, out)
+ }
+ }
+
+ @Test
+ fun `SetRetrieved carries the config payload and overwrites any prior state`() {
+ val next = ConfigState.Updates.SetRetrieved(stubConfig).reduce(ConfigState.Retrieving)
+ assertTrue(next is ConfigState.Retrieved)
+ assertEquals(stubConfig, (next as ConfigState.Retrieved).config)
+
+ // Also from Failed (cold-start recovery path).
+ val next2 = ConfigState.Updates.SetRetrieved(stubConfig).reduce(ConfigState.Failed(stubError))
+ assertTrue(next2 is ConfigState.Retrieved)
+ assertEquals(stubConfig, (next2 as ConfigState.Retrieved).config)
+ }
+
+ @Test
+ fun `SetFailed carries the throwable payload`() {
+ val err = IllegalStateException("oops")
+ val next = ConfigState.Updates.SetFailed(err).reduce(ConfigState.Retrieving)
+ assertTrue(next is ConfigState.Failed)
+ assertEquals(err, (next as ConfigState.Failed).throwable)
+ }
+
+ @Test
+ fun `Set forces any prior state to the supplied state — test-only escape hatch`() {
+ val target = ConfigState.Retrieved(stubConfig)
+ val out = ConfigState.Updates.Set(target).reduce(ConfigState.Failed(stubError))
+ assertSame(target, out)
+ }
+
+ @Test
+ fun `Updates are pure — invoking twice on the same input yields the same output`() {
+ val input = ConfigState.None
+ val a = ConfigState.Updates.SetRetrieving.reduce(input)
+ val b = ConfigState.Updates.SetRetrieving.reduce(input)
+ assertSame(a, b)
+
+ val c = ConfigState.Updates.SetRetrieved(stubConfig).reduce(input)
+ val d = ConfigState.Updates.SetRetrieved(stubConfig).reduce(input)
+ // Retrieved uses data class equality, not identity — assertEquals is the right check.
+ assertEquals(c, d)
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt
index 318ac5a53..bda46571b 100644
--- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt
@@ -119,7 +119,7 @@ class IdentityActorIntegrationTest {
ioScope = IOScope(scope.coroutineContext),
notifyUserChange = {},
completeReset = { resetCalled = true },
- trackEvent = { trackedEvents.add(it) },
+ tracker = { trackedEvents.add(it) },
webPaywallRedeemer = { mockk(relaxed = true) },
actor = actor,
sdkContext = sdkContext,
@@ -300,7 +300,7 @@ class IdentityActorIntegrationTest {
ioScope = IOScope(testActorScope().coroutineContext),
notifyUserChange = {},
completeReset = { resetCount++ },
- trackEvent = { trackedEvents.add(it) },
+ tracker = { trackedEvents.add(it) },
webPaywallRedeemer = { mockk(relaxed = true) },
actor = actor,
sdkContext = sdkContext,
diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt
index ee13acaa5..90c4cbec8 100644
--- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt
@@ -115,7 +115,7 @@ class IdentityManagerTest {
ioScope = scope,
notifyUserChange = { notifiedChanges.add(it) },
completeReset = { resetCalled = true },
- trackEvent = { trackedEvents.add(it) },
+ tracker = { trackedEvents.add(it) },
webPaywallRedeemer = { mockk(relaxed = true) },
actor = testIdentityActor(),
sdkContext = mockk(relaxed = true),
@@ -140,7 +140,7 @@ class IdentityManagerTest {
ioScope = ioScope,
notifyUserChange = { notifiedChanges.add(it) },
completeReset = { resetCalled = true },
- trackEvent = { trackedEvents.add(it) },
+ tracker = { trackedEvents.add(it) },
webPaywallRedeemer = { mockk(relaxed = true) },
actor = testIdentityActor(),
sdkContext = mockk(relaxed = true),
@@ -340,7 +340,7 @@ class IdentityManagerTest {
stringToSha = { "sha256-of-$it" },
notifyUserChange = {},
completeReset = {},
- trackEvent = {},
+ tracker = {},
webPaywallRedeemer = { mockk(relaxed = true) },
actor = testIdentityActor(),
sdkContext = mockk(relaxed = true),
@@ -562,6 +562,7 @@ class IdentityManagerTest {
shouldTrackMerge = true,
)
manager.awaitLatestIdentity()
+ advanceUntilIdle()
}
Then("an Attributes event is tracked") {
@@ -721,6 +722,7 @@ class IdentityManagerTest {
When("identify is called with a new userId") {
manager.identify("user-track-test")
manager.awaitLatestIdentity()
+ advanceUntilIdle()
}
Then("an IdentityAlias event is tracked") {
@@ -785,7 +787,7 @@ class IdentityManagerTest {
ioScope = IOScope(this@runTest.coroutineContext),
notifyUserChange = { notifiedChanges.add(it) },
completeReset = { resetCalled = true },
- trackEvent = { trackedEvents.add(it) },
+ tracker = { trackedEvents.add(it) },
webPaywallRedeemer = { mockk(relaxed = true) },
actor = testIdentityActor(),
sdkContext = sdkContext,
diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt
index 67507a2d8..3a46fbdc6 100644
--- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt
@@ -92,7 +92,7 @@ class IdentityManagerUserAttributesTest {
ioScope = IOScope(scope.coroutineContext),
notifyUserChange = {},
completeReset = { resetCalled = true },
- trackEvent = { trackedEvents.add(it) },
+ tracker = { trackedEvents.add(it) },
webPaywallRedeemer = { mockk(relaxed = true) },
actor = testActor(),
sdkContext = mockk(relaxed = true),
@@ -129,7 +129,7 @@ class IdentityManagerUserAttributesTest {
ioScope = ioScope,
notifyUserChange = {},
completeReset = { resetCalled = true },
- trackEvent = { trackedEvents.add(it) },
+ tracker = { trackedEvents.add(it) },
webPaywallRedeemer = { mockk(relaxed = true) },
actor = testActor(),
sdkContext = mockk(relaxed = true),
diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTest.kt
similarity index 86%
rename from superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeManagerTest.kt
rename to superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTest.kt
index 370d0b069..99ede9666 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTest.kt
@@ -28,13 +28,13 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
-class TestModeManagerTest {
+class TestModeTest {
private fun makeStorage(): Storage = mockk(relaxed = true)
private fun makeManager(
storage: Storage = makeStorage(),
isTestEnvironment: Boolean = false,
- ): TestModeManager = TestModeManager(storage, isTestEnvironment)
+ ): TestMode = TestMode(storage, isTestEnvironment)
private fun makeConfig(
bundleIdConfig: String? = null,
@@ -64,7 +64,7 @@ class TestModeManagerTest {
)
/** Activates test mode via ALWAYS behavior so session data can be written. */
- private fun activateTestMode(manager: TestModeManager) {
+ private fun activateTestMode(manager: TestMode) {
manager.evaluateTestMode(
makeConfig(),
"com.app",
@@ -78,7 +78,7 @@ class TestModeManagerTest {
@Test
fun `evaluateTestMode activates for applicationId mismatch`() {
- Given("a TestModeManager with a config that has a different applicationId") {
+ Given("a TestMode with a config that has a different applicationId") {
val manager = makeManager()
val config = makeConfig(bundleIdConfig = "com.expected.app")
@@ -98,7 +98,7 @@ class TestModeManagerTest {
@Test
fun `evaluateTestMode does not activate for matching bundleId`() {
- Given("a TestModeManager with a config that has the same applicationId") {
+ Given("a TestMode with a config that has the same applicationId") {
val manager = makeManager()
val config = makeConfig(bundleIdConfig = "com.myapp")
@@ -149,7 +149,7 @@ class TestModeManagerTest {
@Test
fun `evaluateTestMode activates for userId match`() {
- Given("a TestModeManager with a config containing a matching userId") {
+ Given("a TestMode with a config containing a matching userId") {
val manager = makeManager()
val config =
makeConfig(
@@ -174,7 +174,7 @@ class TestModeManagerTest {
@Test
fun `evaluateTestMode activates for aliasId match`() {
- Given("a TestModeManager with a config containing a matching aliasId") {
+ Given("a TestMode with a config containing a matching aliasId") {
val manager = makeManager()
val config =
makeConfig(
@@ -225,7 +225,7 @@ class TestModeManagerTest {
@Test
fun `evaluateTestMode does not activate when no conditions match`() {
- Given("a TestModeManager with a config with non-matching users") {
+ Given("a TestMode with a config with non-matching users") {
val manager = makeManager()
val config =
makeConfig(
@@ -490,7 +490,7 @@ class TestModeManagerTest {
@Test
fun `shouldShowFreeTrial applies UseDefault correctly`() {
- Given("a TestModeManager with UseDefault override") {
+ Given("a TestMode with UseDefault override") {
val manager = makeManager()
activateTestMode(manager)
manager.setFreeTrialOverride(FreeTrialOverride.UseDefault)
@@ -504,7 +504,7 @@ class TestModeManagerTest {
@Test
fun `shouldShowFreeTrial applies ForceAvailable correctly`() {
- Given("a TestModeManager with ForceAvailable override") {
+ Given("a TestMode with ForceAvailable override") {
val manager = makeManager()
activateTestMode(manager)
manager.setFreeTrialOverride(FreeTrialOverride.ForceAvailable)
@@ -518,7 +518,7 @@ class TestModeManagerTest {
@Test
fun `shouldShowFreeTrial applies ForceUnavailable correctly`() {
- Given("a TestModeManager with ForceUnavailable override") {
+ Given("a TestMode with ForceUnavailable override") {
val manager = makeManager()
activateTestMode(manager)
manager.setFreeTrialOverride(FreeTrialOverride.ForceUnavailable)
@@ -532,7 +532,7 @@ class TestModeManagerTest {
@Test
fun `shouldShowFreeTrial returns default when inactive`() {
- Given("a TestModeManager that is inactive") {
+ Given("a TestMode that is inactive") {
val manager = makeManager()
Then("it returns the original value regardless") {
@@ -548,7 +548,7 @@ class TestModeManagerTest {
@Test
fun `fakePurchase adds entitlement IDs`() {
- Given("a TestModeManager with active test mode") {
+ Given("a TestMode with active test mode") {
val storage = makeStorage()
val manager = makeManager(storage)
activateTestMode(manager)
@@ -572,7 +572,7 @@ class TestModeManagerTest {
@Test
fun `fakePurchase accumulates entitlements across calls`() {
- Given("a TestModeManager with existing entitlements") {
+ Given("a TestMode with existing entitlements") {
val manager = makeManager()
activateTestMode(manager)
manager.fakePurchase(listOf(SuperwallEntitlementRef("premium", null)))
@@ -593,7 +593,7 @@ class TestModeManagerTest {
@Test
fun `setEntitlements replaces existing entitlements`() {
- Given("a TestModeManager with existing entitlements") {
+ Given("a TestMode with existing entitlements") {
val storage = makeStorage()
val manager = makeManager(storage)
activateTestMode(manager)
@@ -612,7 +612,7 @@ class TestModeManagerTest {
@Test
fun `setEntitlements with empty set writes false`() {
- Given("a TestModeManager with active test mode") {
+ Given("a TestMode with active test mode") {
val storage = makeStorage()
val manager = makeManager(storage)
activateTestMode(manager)
@@ -630,7 +630,7 @@ class TestModeManagerTest {
@Test
fun `resetEntitlements clears all and writes false`() {
- Given("a TestModeManager with entitlements") {
+ Given("a TestMode with entitlements") {
val storage = makeStorage()
val manager = makeManager(storage)
activateTestMode(manager)
@@ -653,7 +653,7 @@ class TestModeManagerTest {
@Test
fun `buildSubscriptionStatus returns Active with entitlements`() {
- Given("a TestModeManager with entitlements set") {
+ Given("a TestMode with entitlements set") {
val manager = makeManager()
activateTestMode(manager)
manager.setEntitlements(setOf("premium", "pro"))
@@ -674,7 +674,7 @@ class TestModeManagerTest {
@Test
fun `buildSubscriptionStatus returns Inactive when empty`() {
- Given("a TestModeManager with no entitlements") {
+ Given("a TestMode with no entitlements") {
val manager = makeManager()
When("buildSubscriptionStatus is called") {
@@ -693,7 +693,7 @@ class TestModeManagerTest {
@Test
fun `setProducts stores products`() {
- Given("a TestModeManager with active test mode") {
+ Given("a TestMode with active test mode") {
val manager = makeManager()
activateTestMode(manager)
val products =
@@ -716,7 +716,7 @@ class TestModeManagerTest {
@Test
fun `setTestProducts stores test product map`() {
- Given("a TestModeManager with active test mode") {
+ Given("a TestMode with active test mode") {
val manager = makeManager()
activateTestMode(manager)
val sp = mockk(relaxed = true)
@@ -739,7 +739,7 @@ class TestModeManagerTest {
@Test
fun `allEntitlements aggregates from all products`() {
- Given("a TestModeManager with products that have entitlements") {
+ Given("a TestMode with products that have entitlements") {
val manager = makeManager()
activateTestMode(manager)
manager.setProducts(
@@ -775,7 +775,7 @@ class TestModeManagerTest {
@Test
fun `allEntitlements returns empty when no products`() {
- Given("a TestModeManager with no products") {
+ Given("a TestMode with no products") {
val manager = makeManager()
Then("allEntitlements returns empty set") {
@@ -786,7 +786,7 @@ class TestModeManagerTest {
@Test
fun `entitlementsForProduct returns entitlements for given product`() {
- Given("a TestModeManager with a product that has entitlements") {
+ Given("a TestMode with a product that has entitlements") {
val manager = makeManager()
val product =
makeSuperwallProduct(
@@ -816,7 +816,7 @@ class TestModeManagerTest {
@Test
fun `setOverriddenSubscriptionStatus stores and clears status`() {
- Given("a TestModeManager with active test mode") {
+ Given("a TestMode with active test mode") {
val manager = makeManager()
activateTestMode(manager)
@@ -845,7 +845,7 @@ class TestModeManagerTest {
@Test
fun `clearTestModeState resets all fields`() {
- Given("a TestModeManager with active test mode") {
+ Given("a TestMode with active test mode") {
val storage = makeStorage()
val manager = makeManager(storage)
val config =
@@ -883,7 +883,7 @@ class TestModeManagerTest {
@Test
fun `state transitions from Inactive to Active and back`() {
- Given("a fresh TestModeManager") {
+ Given("a fresh TestMode") {
val manager = makeManager()
Then("initial state is Inactive") {
@@ -924,7 +924,7 @@ class TestModeManagerTest {
@Test
fun `session data is only accessible when active`() {
- Given("a fresh TestModeManager") {
+ Given("a fresh TestMode") {
val manager = makeManager()
Then("session data returns defaults when inactive") {
@@ -962,4 +962,93 @@ class TestModeManagerTest {
}
// endregion
+
+ // region activate() — UI flow wiring
+
+ @Test
+ fun `activate refreshes products by calling getSuperwallProducts and filters to Android`() =
+ kotlinx.coroutines.test.runTest {
+ val storage = makeStorage()
+ val androidProduct = makeSuperwallProduct("prod-android")
+ val iosProduct =
+ SuperwallProduct(
+ identifier = "prod-ios",
+ platform = SuperwallProductPlatform.IOS,
+ price = SuperwallProductPrice(amount = 999, currency = "USD"),
+ )
+ val response =
+ com.superwall.sdk.store.testmode.models.SuperwallProductsResponse(
+ data = listOf(androidProduct, iosProduct),
+ )
+ var getProductsCalls = 0
+ val manager =
+ TestMode(
+ storage = storage,
+ isTestEnvironment = false,
+ getSuperwallProducts = {
+ getProductsCalls++
+ com.superwall.sdk.misc.Either.Success(response)
+ },
+ // No activity available → activate short-circuits the modal step.
+ activityProvider = { null },
+ activityTracker = { null },
+ )
+ activateTestMode(manager)
+
+ // justActivated = false → only refreshes products, never tries the modal.
+ manager.activate(makeConfig(), justActivated = false)
+
+ assertEquals(1, getProductsCalls)
+ assertEquals(
+ "Only Android products should be registered",
+ listOf("prod-android"),
+ manager.products.map { it.identifier },
+ )
+ }
+
+ @Test
+ fun `activate with empty product response leaves the catalog empty`() =
+ kotlinx.coroutines.test.runTest {
+ val storage = makeStorage()
+ val manager =
+ TestMode(
+ storage = storage,
+ isTestEnvironment = false,
+ getSuperwallProducts = {
+ com.superwall.sdk.misc.Either.Success(
+ com.superwall.sdk.store.testmode.models.SuperwallProductsResponse(data = emptyList()),
+ )
+ },
+ )
+ activateTestMode(manager)
+
+ manager.activate(makeConfig(), justActivated = false)
+
+ assertTrue(manager.products.isEmpty())
+ }
+
+ @Test
+ fun `activate swallows network failure and leaves products unchanged`() =
+ kotlinx.coroutines.test.runTest {
+ val storage = makeStorage()
+ val manager =
+ TestMode(
+ storage = storage,
+ isTestEnvironment = false,
+ getSuperwallProducts = {
+ com.superwall.sdk.misc.Either.Failure(
+ com.superwall.sdk.network.NetworkError.Unknown(),
+ )
+ },
+ )
+ activateTestMode(manager)
+ // Seed a product so we can verify the failure path leaves it untouched.
+ manager.setProducts(listOf(makeSuperwallProduct("prev")))
+
+ manager.activate(makeConfig(), justActivated = false)
+
+ assertEquals(listOf("prev"), manager.products.map { it.identifier })
+ }
+
+ // endregion
}
diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt
index fbfbe2713..600b476e2 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt
@@ -20,8 +20,8 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class TestModeTransactionHandlerTest {
- private fun makeTestModeManager(): TestModeManager {
- val manager = TestModeManager(mockk(relaxed = true))
+ private fun makeTestMode(): TestMode {
+ val manager = TestMode(mockk(relaxed = true))
// Activate test mode so session data can be written
val config = mockk(relaxed = true)
manager.evaluateTestMode(config, "com.app", null, null, TestModeBehavior.ALWAYS)
@@ -48,7 +48,7 @@ class TestModeTransactionHandlerTest {
@Test
fun `findSuperwallProductForId returns matching product`() {
Given("a TestModeTransactionHandler with products") {
- val manager = makeTestModeManager()
+ val manager = makeTestMode()
manager.setProducts(
listOf(
makeProduct("com.test.monthly"),
@@ -71,7 +71,7 @@ class TestModeTransactionHandlerTest {
@Test
fun `findSuperwallProductForId returns null for unknown id`() {
Given("a TestModeTransactionHandler with products") {
- val manager = makeTestModeManager()
+ val manager = makeTestMode()
manager.setProducts(listOf(makeProduct("com.test.monthly")))
val handler = TestModeTransactionHandler(manager, mockk(relaxed = true))
@@ -88,7 +88,7 @@ class TestModeTransactionHandlerTest {
@Test
fun `entitlementsForProduct returns entitlement set`() {
Given("a handler with products that have entitlements") {
- val manager = makeTestModeManager()
+ val manager = makeTestMode()
manager.setProducts(
listOf(
makeProduct(
@@ -118,7 +118,7 @@ class TestModeTransactionHandlerTest {
@Test
fun `entitlementsForProduct returns empty set for unknown product`() {
Given("a handler with products") {
- val manager = makeTestModeManager()
+ val manager = makeTestMode()
manager.setProducts(listOf(makeProduct("com.test.monthly")))
val handler = TestModeTransactionHandler(manager, mockk(relaxed = true))
@@ -135,7 +135,7 @@ class TestModeTransactionHandlerTest {
@Test
fun `entitlementsForProduct returns empty set for product without entitlements`() {
Given("a handler with a product that has no entitlements") {
- val manager = makeTestModeManager()
+ val manager = makeTestMode()
manager.setProducts(
listOf(
makeProduct("com.test.basic", entitlements = emptyList()),