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 @@ -branches33.2% \ No newline at end of file +branches33.4% 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 @@ -coverage42.8% \ No newline at end of file +coverage43% \ 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 {