diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 097c3a25e..c5694ffc8 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 13424a652..45e14ae7a 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 75cbe4334..ca9a8543c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ This is the Superwall Android SDK - an open-source framework for remotely config
./gradlew :app:connectedCheck
# Run integration tests with screenshot recording
-./gradlew :app:connectedCheck -Pdropshots.record
+./gradlew recordDebugAndroidTestScreenshots
# Build and publish SDK locally
./gradlew publishToMavenLocal
diff --git a/app/src/androidTest/README.md b/app/src/androidTest/README.md
index 0284e39d6..fd901e296 100644
--- a/app/src/androidTest/README.md
+++ b/app/src/androidTest/README.md
@@ -16,7 +16,7 @@ If there are failing tests, the screenshots will be saved under `app/build/outpu
### Recording the screenshots
-To record the screenshots, run `./gradlew :app:connectedCheck -Pdropshots.record` from the root of the project.
+To record the screenshots, run `./gradlew recordDebugAndroidTestScreenshots` from the root of the project.
This will record new screenshots on your current device.
### Viewing the recorded screenshots
diff --git a/app/src/androidTest/java/com/example/superapp/test/AlternativeSetupTest.kt b/app/src/androidTest/java/com/example/superapp/test/AlternativeSetupTest.kt
index 1b3eb335c..5b15e1310 100644
--- a/app/src/androidTest/java/com/example/superapp/test/AlternativeSetupTest.kt
+++ b/app/src/androidTest/java/com/example/superapp/test/AlternativeSetupTest.kt
@@ -29,12 +29,11 @@ class AlternativeSetupTest {
@Before
fun grantPhonePermission() {
+ // Shouldn't be needed on > API 29, but dropshots is occasionally unable to write to external storage without this.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- getInstrumentation().uiAutomation.executeShellCommand(
- (
- "pm grant " + getTargetContext().packageName +
- " android.permission.WRITE_EXTERNAL_STORAGE"
- ),
+ getInstrumentation().uiAutomation.grantRuntimePermission(
+ getTargetContext().packageName,
+ android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
)
}
}
diff --git a/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt b/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt
index 03e91ef78..900099884 100644
--- a/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt
+++ b/app/src/androidTest/java/com/example/superapp/test/FlowScreenshotTestExecutor.kt
@@ -46,12 +46,11 @@ class FlowScreenshotTestExecutor {
@Before
fun grantPhonePermission() {
+ // Shouldn't be needed on > API 29, but dropshots is occasionally unable to write to external storage without this.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- getInstrumentation().uiAutomation.executeShellCommand(
- (
- "pm grant " + getTargetContext().packageName +
- " android.permission.WRITE_EXTERNAL_STORAGE"
- ),
+ getInstrumentation().uiAutomation.grantRuntimePermission(
+ getTargetContext().packageName,
+ android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
)
}
}
diff --git a/app/src/androidTest/java/com/example/superapp/test/NoConnectionTestExecutor.kt b/app/src/androidTest/java/com/example/superapp/test/NoConnectionTestExecutor.kt
index 300602392..6bed4f900 100644
--- a/app/src/androidTest/java/com/example/superapp/test/NoConnectionTestExecutor.kt
+++ b/app/src/androidTest/java/com/example/superapp/test/NoConnectionTestExecutor.kt
@@ -40,12 +40,11 @@ class NoConnectionTestExecutor {
@Before
fun grantPhonePermission() {
+ // Shouldn't be needed on > API 29, but dropshots is occasionally unable to write to external storage without this.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- getInstrumentation().uiAutomation.executeShellCommand(
- (
- "pm grant " + getTargetContext().packageName +
- " android.permission.WRITE_EXTERNAL_STORAGE"
- ),
+ getInstrumentation().uiAutomation.grantRuntimePermission(
+ getTargetContext().packageName,
+ android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
)
}
}
diff --git a/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt b/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt
index 9d3eb1163..6f4856680 100644
--- a/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt
+++ b/app/src/androidTest/java/com/example/superapp/test/PresentationRuleTests.kt
@@ -33,12 +33,11 @@ class PresentationRuleTests {
@Before
fun grantPhonePermission() {
+ // Shouldn't be needed on > API 29, but dropshots is occasionally unable to write to external storage without this.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- getInstrumentation().uiAutomation.executeShellCommand(
- (
- "pm grant " + getTargetContext().packageName +
- " android.permission.WRITE_EXTERNAL_STORAGE"
- ),
+ getInstrumentation().uiAutomation.grantRuntimePermission(
+ getTargetContext().packageName,
+ android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
)
}
}
diff --git a/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt b/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt
index da636bac5..108d5ae7f 100644
--- a/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt
+++ b/app/src/androidTest/java/com/example/superapp/test/SimpleScreenshotTestExecutor.kt
@@ -44,12 +44,11 @@ class SimpleScreenshotTestExecutor {
@Before
fun grantPhonePermission() {
+ // Shouldn't be needed on > API 29, but dropshots is occasionally unable to write to external storage without this.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- getInstrumentation().uiAutomation.executeShellCommand(
- (
- "pm grant " + getTargetContext().packageName +
- " android.permission.WRITE_EXTERNAL_STORAGE"
- ),
+ getInstrumentation().uiAutomation.grantRuntimePermission(
+ getTargetContext().packageName,
+ android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
)
}
}
diff --git a/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt b/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt
index 6e258476e..c65814baf 100644
--- a/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt
+++ b/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt
@@ -2,6 +2,7 @@ package com.example.superapp.utils
import android.os.Environment
import android.util.Log
+import androidx.test.platform.app.InstrumentationRegistry
import com.superwall.superapp.test.UITestInfo
import org.json.JSONArray
import org.json.JSONObject
@@ -15,10 +16,11 @@ private const val TAG = "EventTimeline"
/**
* Writes the event timeline for a [UITestInfo] to a JSON file on device storage.
*
- * Output directory: /sdcard/Download/superwall-event-timelines/
+ * Output directory: /sdcard/Android/data//files/Download/superwall-event-timelines/
+ * (app-scoped external storage — no runtime permission required on API 30+).
*
* Pull results with:
- * adb pull /sdcard/Download/superwall-event-timelines/ app/build/outputs/event-timelines/
+ * adb pull /sdcard/Android/data//files/Download/superwall-event-timelines/ app/build/outputs/event-timelines/
*/
fun writeTimelineToFile(
testInfo: UITestInfo,
@@ -28,10 +30,14 @@ fun writeTimelineToFile(
val timeline = testInfo.timeline
if (timeline.allEvents().isEmpty()) return
- val dir = File(
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
- TIMELINE_DIR,
- )
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val externalDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
+ if (externalDir == null) {
+ Log.w(TAG, "External storage unavailable; writing timelines to internal storage. " +
+ "Use 'adb shell run-as ${context.packageName}' to access files.")
+ }
+ val baseDir = externalDir ?: context.filesDir
+ val dir = File(baseDir, TIMELINE_DIR)
dir.mkdirs()
val fileName = "${testClassName}_${testMethodName}.json"
diff --git a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt
index c6cf58431..6f47897e4 100644
--- a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt
+++ b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt
@@ -45,7 +45,9 @@ class ScreenshotTestFlow(
) {
var steps: LinkedList = LinkedList()
- private val device = "${android.os.Build.MANUFACTURER}_${android.os.Build.MODEL}"
+ // Pinned to a stable identifier so snapshot filenames don't shift when the
+ // emulator system image renames Build.MODEL (e.g. sdk_gphone_arm64 vs sdk_gphone64_arm64).
+ private val device = "Google_sdk_gphone64_arm64"
@ScreenshotTestDSL
fun step(
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b128a432b..718027d43 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/app/src/main/java/com/superwall/superapp/MainApplication.kt b/app/src/main/java/com/superwall/superapp/MainApplication.kt
index 233df75bb..641aa1a88 100644
--- a/app/src/main/java/com/superwall/superapp/MainApplication.kt
+++ b/app/src/main/java/com/superwall/superapp/MainApplication.kt
@@ -83,7 +83,7 @@ class MainApplication :
logging.level = LogLevel.debug
paywalls =
PaywallOptions().apply {
- shouldPreload = false
+ shouldPreload = true
}
},
)
diff --git a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt
index 1a99bd45d..323a7bb2d 100644
--- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt
+++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt
@@ -47,7 +47,9 @@ object UITestHandler {
"${it.id}"
}.joinToString(separator = ", "),
)
- Superwall.instance.register(placement = "show_if_web_failed")
+ Superwall.instance.setUserAttributes(mapOf("is_user_eligible_for_dd_offer" to true))
+ Superwall.instance.register(placement = "swtest")
+ Superwall.instance.setUserAttributes(mapOf("is_user_eligible_for_dd_offer" to null))
},
),
UITestInfo(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1c3e63343..268687245 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -32,7 +32,7 @@ threetenbp_version = "1.6.8"
uiautomator_version = "2.3.0"
workRuntimeKtx_version = "2.9.0"
serialization_version = "1.6.0"
-dropshot_version = "0.4.2"
+dropshot_version = "0.5.0"
ksp = "2.0.21-1.0.27"
install_referrer = "2.2"
publisher_version = "0.33.0"
diff --git a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt
index e40283266..f9a54ed5e 100644
--- a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt
+++ b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt
@@ -3,6 +3,8 @@ package com.superwall.sdk
import com.superwall.sdk.config.ConfigManager
import com.superwall.sdk.misc.awaitFirstValidConfig
import com.superwall.sdk.models.config.Config
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration.Companion.seconds
/**
* Cross-slice bridge used by the identity actor to call into other managers.
@@ -25,9 +27,13 @@ class SdkContextImpl(
}
override suspend fun fetchAssignments() {
- configManager().getAssignments()
+ withTimeoutOrNull(30.seconds) {
+ configManager().getAssignments()
+ }
}
override suspend fun awaitConfig(): Config? =
- configManager().configState.awaitFirstValidConfig()
+ withTimeoutOrNull(30.seconds) {
+ configManager().configState.awaitFirstValidConfig()
+ }
}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt
index 31b8f5447..88dedddca 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt
@@ -21,8 +21,29 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
+/**
+ * Waits for [ConfigState.Retrieved], retrying once after an initial 1-second window.
+ * Throws immediately on [ConfigState.Failed] or when all retries are exhausted.
+ *
+ * @param retriesLeft remaining retry attempts; each attempt uses a progressively
+ * longer timeout (1s per non-final attempt, 5s for the final one).
+ */
+internal suspend fun StateFlow.configOrThrow(retriesLeft: Int = 1) {
+ try {
+ withTimeout(if (retriesLeft > 0) 1.seconds else 5.seconds) {
+ first { result ->
+ if (result is ConfigState.Failed) throw result.throwable
+ result is ConfigState.Retrieved
+ }
+ }
+ } catch (e: TimeoutCancellationException) {
+ if (retriesLeft > 0) configOrThrow(retriesLeft - 1) else throw e
+ }
+}
+
internal suspend fun waitForEntitlementsAndConfig(
request: PresentationRequest,
paywallStatePublisher: MutableSharedFlow? = null,
@@ -70,79 +91,76 @@ internal suspend fun waitForEntitlementsAndConfig(
val configState = dependencyContainer.configManager.configState
- suspend fun StateFlow.configOrThrow() {
- first { result ->
- if (result is ConfigState.Failed) throw result.throwable
- result is ConfigState.Retrieved
+ // In-flight states get one retry (1s initial window, then 5s fallback).
+ // Already-resolved states (Retrieved/Failed) complete on the first attempt.
+ val retries =
+ if (configState.value is ConfigState.Retrieving ||
+ configState.value is ConfigState.Retrying ||
+ configState.value is ConfigState.None
+ ) {
+ 1
+ } else {
+ 0
}
- }
- when {
- // Config is still retrieving, wait for <=1 second.
- // At 1s we cancel the task and check config again.
- configState.value is ConfigState.Retrieving -> {
- try {
- withTimeout(1.seconds) {
- configState
- .configOrThrow()
- }
- } catch (e: TimeoutCancellationException) {
- try {
- // Check config again just in case
- configState.configOrThrow()
- } catch (e: Exception) {
- e.printStackTrace()
- dependencyContainer.ioScope().launch {
- val trackedEvent =
- InternalSuperwallEvent.PresentationRequest(
- eventData = request.presentationInfo.eventData,
- type = request.flags.type,
- status = PaywallPresentationRequestStatus.Timeout,
- statusReason = PaywallPresentationRequestStatusReason.NoConfig(),
- factory = dependencyContainer,
- )
- dependencyContainer.track(trackedEvent)
- }
- Logger.debug(
- logLevel = LogLevel.info,
- scope = LogScope.paywallPresentation,
- message = "Timeout: The config could not be retrieved in a reasonable time for a subscribed user.",
+ try {
+ configState.configOrThrow(retries)
+ } catch (e: Throwable) {
+ e.printStackTrace()
+ // Only track when config timed out — a Failed state is an immediate error, not a timeout.
+ if (e is TimeoutCancellationException) {
+ dependencyContainer.ioScope().launch {
+ val trackedEvent =
+ InternalSuperwallEvent.PresentationRequest(
+ eventData = request.presentationInfo.eventData,
+ type = request.flags.type,
+ status = PaywallPresentationRequestStatus.Timeout,
+ statusReason = PaywallPresentationRequestStatusReason.NoConfig(),
+ factory = dependencyContainer,
)
- val state =
- PaywallState.PresentationError(
- InternalPresentationLogic.presentationError(
- domain = "SWKPresentationError",
- code = 104,
- title = "No Config",
- value = "Trying to present paywall without the superwall config.",
- ),
- )
- paywallStatePublisher?.emit(state)
- throw PaywallPresentationRequestStatusReason.NoConfig()
- }
+ dependencyContainer.track(trackedEvent)
}
}
-
- else -> {
- // Try to get the config and continue or throw an error
- try {
- configState.configOrThrow()
- } catch (e: Throwable) {
- e.printStackTrace()
- // If config completely dies, then throw an error
- val error =
- InternalPresentationLogic.presentationError(
- domain = "SWKPresentationError",
- code = 104,
- title = "No Config",
- value = "Trying to present paywall without the Superwall config. Error: ${e.message}}",
- )
- val state = PaywallState.PresentationError(error)
- paywallStatePublisher?.emit(state)
- throw PaywallPresentationRequestStatusReason.NoConfig()
+ Logger.debug(
+ logLevel = LogLevel.info,
+ scope = LogScope.paywallPresentation,
+ message = "Timeout: The config could not be retrieved in a reasonable time.",
+ )
+ val errorValue =
+ if (e is TimeoutCancellationException) {
+ "Trying to present paywall without the Superwall config."
+ } else {
+ "Trying to present paywall without the Superwall config. Error: ${e.message}"
}
- }
+ paywallStatePublisher?.emit(
+ PaywallState.PresentationError(
+ InternalPresentationLogic.presentationError(
+ domain = "SWKPresentationError",
+ code = 104,
+ title = "No Config",
+ value = errorValue,
+ ),
+ ),
+ )
+ throw PaywallPresentationRequestStatusReason.NoConfig()
}
- dependencyContainer.identityManager.awaitLatestIdentity()
+ // Defense in depth: if a Pending identity item (Seed / Assignments / etc.)
+ // never resolves — e.g. because an upstream coroutine got orphaned or
+ // the underlying flow never emits — don't let paywall presentation hang
+ // forever. Falls through after the timeout; the paywall presents with
+ // whatever identity state is current.
+ val identityResolved =
+ withTimeoutOrNull(5.seconds) {
+ dependencyContainer.identityManager.awaitLatestIdentity()
+ }
+ if (identityResolved == null) {
+ Logger.debug(
+ logLevel = LogLevel.warn,
+ scope = LogScope.paywallPresentation,
+ message =
+ "Timeout: identity did not become ready within 5s. " +
+ "Proceeding with current identity state.",
+ )
+ }
}
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 401f18b09..9787be99b 100644
--- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt
@@ -25,9 +25,12 @@ import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration.Companion.seconds
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
@@ -453,6 +456,149 @@ class IdentityActorIntegrationTest {
// Persistence interceptor under serialization
// -----------------------------------------------------------------------
+ // -----------------------------------------------------------------------
+ // Hang theories: prove that an unbounded suspension in a downstream call
+ // leaves a Pending item in the identity state forever, which blocks
+ // awaitLatestIdentity().
+ // -----------------------------------------------------------------------
+
+ /**
+ * Theory 3: if `sdkContext.fetchAssignments()` never returns (e.g. it's
+ * waiting on `awaitFirstValidConfig()` and config never reaches
+ * `Retrieved`), the `try/finally` in `FetchAssignments` never reaches
+ * the `finally` block, so `Updates.AssignmentsCompleted` is never
+ * dispatched and `Pending.Assignments` remains in the state — blocking
+ * `awaitLatestIdentity()` indefinitely.
+ */
+ @Test
+ fun `fetchAssignments hang leaves Pending Assignments and blocks awaitLatestIdentity`() = runTest {
+ Given("a configured ready manager where fetchAssignments suspends forever") {
+ coEvery { sdkContext.fetchAssignments() } coAnswers { awaitCancellation() }
+ coEvery { sdkContext.awaitConfig() } returns null
+
+ val manager = createSequentialManager(scope = backgroundScope)
+ every { storage.read(DidTrackFirstSeen) } returns true
+
+ manager.configure(neverCalledStaticConfig = false)
+ manager.hasIdentity.first()
+ assertTrue("ready before identify", manager.actor.state.value.isReady)
+
+ When("identify is called with restorePaywallAssignments = true") {
+ // restorePaywallAssignments=true adds Pending.Assignments to the
+ // state in Updates.Identify, and FetchAssignments is dispatched
+ // (here as `immediate`) — but its suspend never returns.
+ manager.identify(
+ "user-1",
+ IdentityOptions(restorePaywallAssignments = true),
+ )
+
+ val result =
+ withTimeoutOrNull(10.seconds) {
+ manager.awaitLatestIdentity()
+ }
+
+ Then("awaitLatestIdentity does not return within the timeout") {
+ assertNull("expected hang, got: $result", result)
+ }
+ And("Pending.Assignments is still in the state") {
+ val pending = manager.actor.state.value.pending
+ assertTrue(
+ "expected Pending.Assignments, was: $pending",
+ pending.contains(IdentityState.Pending.Assignments),
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Theory 4: `ResolveSeed` calls `sdkContext.awaitConfig()` which routes
+ * to `awaitFirstValidConfig()` — an unbounded `first()` over a
+ * `ConfigState` flow. If config never reaches `Retrieved` (and never
+ * throws), the call suspends forever, `Pending.Seed` is never resolved,
+ * and `awaitLatestIdentity()` hangs even though `FetchAssignments`
+ * itself completes fine.
+ */
+ @Test
+ fun `awaitConfig hang leaves Pending Seed and blocks awaitLatestIdentity`() = runTest {
+ Given("a configured ready manager where awaitConfig suspends forever") {
+ coEvery { sdkContext.fetchAssignments() } returns Unit
+ coEvery { sdkContext.awaitConfig() } coAnswers { awaitCancellation() }
+
+ val manager = createSequentialManager(scope = backgroundScope)
+ every { storage.read(DidTrackFirstSeen) } returns true
+
+ manager.configure(neverCalledStaticConfig = false)
+ manager.hasIdentity.first()
+ assertTrue("ready before identify", manager.actor.state.value.isReady)
+
+ When("identify is called (default options — no restore)") {
+ // Default Identify adds Pending.Seed and dispatches
+ // effect(ResolveSeed). ResolveSeed will suspend on awaitConfig().
+ manager.identify("user-1")
+
+ val result =
+ withTimeoutOrNull(10.seconds) {
+ manager.awaitLatestIdentity()
+ }
+
+ Then("awaitLatestIdentity does not return within the timeout") {
+ assertNull("expected hang, got: $result", result)
+ }
+ And("Pending.Seed is still in the state") {
+ val pending = manager.actor.state.value.pending
+ assertTrue(
+ "expected Pending.Seed, was: $pending",
+ pending.contains(IdentityState.Pending.Seed),
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Confirms the post-fix Failed path: when `awaitConfig()` throws (which
+ * is what `awaitFirstValidConfig()` now does on `ConfigState.Failed`),
+ * `ResolveSeed`'s `catch (_: Exception)` updates `SeedSkipped`,
+ * `Pending.Seed` resolves, and `awaitLatestIdentity()` returns.
+ */
+ @Test
+ fun `awaitConfig throwing clears Pending Seed and unblocks awaitLatestIdentity`() = runTest {
+ Given("a configured ready manager where awaitConfig throws (simulating Failed)") {
+ coEvery { sdkContext.fetchAssignments() } returns Unit
+ coEvery { sdkContext.awaitConfig() } throws RuntimeException("config failed")
+
+ val manager = createSequentialManager(scope = backgroundScope)
+ every { storage.read(DidTrackFirstSeen) } returns true
+
+ manager.configure(neverCalledStaticConfig = false)
+ manager.hasIdentity.first()
+
+ When("identify is called") {
+ manager.identify("user-1")
+
+ val result =
+ withTimeoutOrNull(5.seconds) {
+ manager.awaitLatestIdentity()
+ }
+
+ Then("awaitLatestIdentity returns (no hang)") {
+ assertNotNull("awaitLatestIdentity should return", result)
+ }
+ And("Pending.Seed is cleared") {
+ val pending = manager.actor.state.value.pending
+ assertFalse(
+ "Pending.Seed should be resolved, pending = $pending",
+ pending.contains(IdentityState.Pending.Seed),
+ )
+ }
+ And("identity is Ready") {
+ assertTrue(manager.actor.state.value.isReady)
+ }
+ }
+ }
+ }
+
@Test
fun `persistence interceptor writes only changed fields`() = runTest {
Given("a fresh manager with SequentialActor") {
diff --git a/superwall/src/test/java/com/superwall/sdk/misc/AwaitFirstValidConfigTest.kt b/superwall/src/test/java/com/superwall/sdk/misc/AwaitFirstValidConfigTest.kt
new file mode 100644
index 000000000..fc0c396ca
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/misc/AwaitFirstValidConfigTest.kt
@@ -0,0 +1,75 @@
+package com.superwall.sdk.misc
+
+import com.superwall.sdk.config.models.ConfigState
+import com.superwall.sdk.models.config.Config
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * `awaitFirstValidConfig` deliberately filters out non-`Retrieved` states
+ * (including `Failed`) so it cooperates with the retry model:
+ * `Failed → Retrying → Retrieved` should resume waiters, not error them out.
+ * These tests guard that contract.
+ */
+class AwaitFirstValidConfigTest {
+ private val stubConfig = Config.stub()
+ private val laterConfig = Config.stub()
+
+ @Test
+ fun `returns the Config when Retrieved is observed`() = runTest {
+ val flow = flowOf(ConfigState.Retrieved(stubConfig))
+
+ val result = flow.awaitFirstValidConfig()
+
+ assertSame(stubConfig, result)
+ }
+
+ @Test
+ fun `skips Failed and resumes once Retrieved arrives (supports retry model)`() = runTest {
+ val state = MutableStateFlow(ConfigState.Retrieving)
+
+ val resultJob =
+ backgroundScope.launch {
+ val resumed = state.awaitFirstValidConfig()
+ assertSame(laterConfig, resumed)
+ }
+
+ // Transient Failed (e.g. between retries) must not unblock the awaiter.
+ state.value = ConfigState.Failed(RuntimeException("transient"))
+ val earlyExitAfterFailed =
+ withTimeoutOrNull(1.seconds) {
+ resultJob.join()
+ }
+ assertNull("Failed must not resolve the awaiter", earlyExitAfterFailed)
+ assertTrue("awaiter still active after Failed", resultJob.isActive)
+
+ // Retry path lands on Retrieved — awaiter resumes with that config.
+ state.value = ConfigState.Retrying
+ state.value = ConfigState.Retrieved(laterConfig)
+ resultJob.join()
+ assertTrue("awaiter completed on Retrieved", resultJob.isCompleted)
+ }
+
+ @Test
+ fun `skips intermediate non-terminal states and returns on Retrieved`() = runTest {
+ val flow =
+ flowOf(
+ ConfigState.None,
+ ConfigState.Retrieving,
+ ConfigState.Retrying,
+ ConfigState.Retrieved(stubConfig),
+ )
+
+ val result = flow.awaitFirstValidConfig()
+
+ assertSame(stubConfig, result)
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt
index 5dfd8236f..13a84869e 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfigTest.kt
@@ -19,11 +19,18 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Test
+import kotlin.test.assertFailsWith
+import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
class WaitForSubsStatusAndConfigTest {
@After
@@ -66,6 +73,176 @@ class WaitForSubsStatusAndConfigTest {
}
}
+ // -----------------------------------------------------------------------
+ // configOrThrow — recursive retry logic
+ // -----------------------------------------------------------------------
+
+ @Test
+ fun `configOrThrow returns immediately when state is already Retrieved`() =
+ runTest {
+ Given("config state is Retrieved") {
+ val state = MutableStateFlow(ConfigState.Retrieved(Config.stub()))
+ When("configOrThrow is called") {
+ state.configOrThrow()
+ Then("it returns without waiting") { }
+ }
+ }
+ }
+
+ @Test
+ fun `configOrThrow throws the stored throwable when state is Failed`() =
+ runTest {
+ val cause = RuntimeException("bad config")
+ Given("config state is Failed with a specific cause") {
+ val state = MutableStateFlow(ConfigState.Failed(cause))
+ When("configOrThrow is called") {
+ val thrown = assertFailsWith { state.configOrThrow() }
+ Then("it throws the throwable stored in ConfigState.Failed") {
+ assertEquals(cause.message, thrown.message)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `configOrThrow retries and succeeds when state transitions to Retrieved after initial timeout`() =
+ runTest {
+ Given("config starts Retrieving and transitions to Retrieved at 1500ms") {
+ val state = MutableStateFlow(ConfigState.Retrieving)
+ backgroundScope.launch {
+ delay(1500.milliseconds)
+ state.value = ConfigState.Retrieved(Config.stub())
+ }
+ When("configOrThrow is called with retriesLeft=1") {
+ state.configOrThrow(retriesLeft = 1)
+ Then("it returns after the retry picks up the transition") { }
+ }
+ }
+ }
+
+ @Test
+ fun `configOrThrow succeeds immediately when Retrying transitions to Retrieved within 1s`() =
+ runTest {
+ Given("config starts Retrying and transitions to Retrieved at 500ms") {
+ val state = MutableStateFlow(ConfigState.Retrying)
+ backgroundScope.launch {
+ delay(500.milliseconds)
+ state.value = ConfigState.Retrieved(Config.stub())
+ }
+ When("configOrThrow is called") {
+ state.configOrThrow(retriesLeft = 1)
+ Then("it returns without exhausting the retry") { }
+ }
+ }
+ }
+
+ @Test
+ fun `configOrThrow throws TimeoutCancellationException when config never arrives`() =
+ runTest {
+ Given("config stays Retrieving indefinitely") {
+ val state = MutableStateFlow(ConfigState.Retrieving)
+ When("configOrThrow is called with retriesLeft=1") {
+ assertFailsWith {
+ state.configOrThrow(retriesLeft = 1)
+ }
+ Then("it throws after the total 6s window (1s + 5s)") { }
+ }
+ }
+ }
+
+ @Test
+ fun `configOrThrow with retriesLeft=0 throws after 5s when config never arrives`() =
+ runTest {
+ Given("config stays Retrieving with no retries allowed") {
+ val state = MutableStateFlow(ConfigState.Retrieving)
+ When("configOrThrow is called with retriesLeft=0") {
+ assertFailsWith {
+ state.configOrThrow(retriesLeft = 0)
+ }
+ Then("it throws after the 5s final-attempt window") { }
+ }
+ }
+ }
+
+ @Test
+ fun `configOrThrow propagates Failed throwable that arrives during the retry window`() =
+ runTest {
+ val cause = RuntimeException("failed mid-retry")
+ Given("config starts Retrieving and transitions to Failed during the 5s retry window") {
+ val state = MutableStateFlow(ConfigState.Retrieving)
+ backgroundScope.launch {
+ delay(1500.milliseconds) // after the 1s initial timeout fires
+ state.value = ConfigState.Failed(cause)
+ }
+ When("configOrThrow is called with retriesLeft=1") {
+ val thrown = assertFailsWith {
+ state.configOrThrow(retriesLeft = 1)
+ }
+ Then("it throws the stored cause immediately, not a TimeoutCancellationException") {
+ assertEquals(cause.message, thrown.message)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `configOrThrow treats None state the same as Retrieving`() =
+ runTest {
+ Given("config state is None and transitions to Retrieved at 500ms") {
+ val state = MutableStateFlow(ConfigState.None)
+ backgroundScope.launch {
+ delay(500.milliseconds)
+ state.value = ConfigState.Retrieved(Config.stub())
+ }
+ When("configOrThrow is called") {
+ state.configOrThrow(retriesLeft = 1)
+ Then("it returns after the transition") { }
+ }
+ }
+ }
+
+ @Test
+ fun `waitForEntitlementsAndConfig throws NoConfig and emits error when config times out`() =
+ runTest {
+ Given("entitlements ready but config stays Retrieving beyond the total timeout") {
+ val dependencyContainer = mockk(relaxed = true)
+ every { dependencyContainer.ioScope() } returns com.superwall.sdk.misc.IOScope(coroutineContext)
+
+ val configState = MutableStateFlow(ConfigState.Retrieving)
+ every { dependencyContainer.configManager.configState } returns configState
+
+ val identityManager = mockk(relaxed = true)
+ coEvery { identityManager.awaitLatestIdentity() } returns mockk(relaxed = true)
+ every { dependencyContainer.identityManager } returns identityManager
+
+ mockkStatic("com.superwall.sdk.analytics.internal.TrackingKt")
+
+ val publisher = MutableSharedFlow(replay = 1)
+ val request =
+ PresentationRequest(
+ presentationInfo = PresentationInfo.ExplicitTrigger(EventData.stub()),
+ flags =
+ PresentationRequest.Flags(
+ isDebuggerLaunched = false,
+ entitlements = MutableStateFlow(SubscriptionStatus.Inactive),
+ isPaywallPresented = false,
+ type = PresentationRequestType.Presentation,
+ ),
+ )
+
+ When("waitForEntitlementsAndConfig executes") {
+ assertFailsWith {
+ waitForEntitlementsAndConfig(request, paywallStatePublisher = publisher, dependencyContainer = dependencyContainer)
+ }
+
+ Then("a presentation error is emitted to the publisher") {
+ val emitted = publisher.replayCache.lastOrNull()
+ assert(emitted is PaywallState.PresentationError)
+ }
+ }
+ }
+ }
+
@Test
fun `waitForEntitlementsAndConfig emits error and throws when config is missing`() =
runTest {