diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 2f2376c36..c0c8757f6 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index 9076cc635..f0c8dbed0 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/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml
new file mode 100644
index 000000000..b74bcd40f
--- /dev/null
+++ b/.github/workflows/pullfrog.yml
@@ -0,0 +1,79 @@
+# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
+name: Pullfrog
+run-name: ${{ inputs.name || github.workflow }}
+on:
+ workflow_dispatch:
+ inputs:
+ prompt:
+ type: string
+ description: Agent prompt
+ name:
+ type: string
+ description: Run name
+
+permissions:
+ contents: read
+
+jobs:
+ pullfrog:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ - name: Run agent
+ uses: pullfrog/pullfrog@v0
+ with:
+ prompt: ${{ inputs.prompt }}
+ env:
+ # add at least one provider API key
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ GOOGLE_GENERATIVE_AI_API_KEY:
+ ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
+ GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+ XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
+ DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+ MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
+ KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+ AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}
+ OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+
+ # for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
+ # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
+ # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ # AWS_REGION: us-east-1
+ # BEDROCK_MODEL_ID:
+
+ # for Google Vertex AI (https://docs.pullfrog.com/vertex)
+ # VERTEX_SERVICE_ACCOUNT_JSON: >-
+ # ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
+ # GOOGLE_CLOUD_PROJECT: my-project
+ # VERTEX_LOCATION: global
+ # VERTEX_MODEL_ID:
+
+ # for Azure OpenAI (https://docs.pullfrog.com/azure)
+ # AZURE_RESOURCE_NAME is the in https://.openai.azure.com
+ # AZURE_DEPLOYMENT is your deployment's name, not the model it serves
+ # AZURE_RESOURCE_NAME: my-resource
+ # AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }}
+ # AZURE_DEPLOYMENT:
+ # both limits are required — set them to the real limits of that model
+ # AZURE_CONTEXT: "400000"
+ # AZURE_MAX_OUTPUT: "128000"
+
+ # for any OpenAI-compatible endpoint — LiteLLM, Cloudflare AI Gateway,
+ # self-hosted vLLM (https://docs.pullfrog.com/openai-compatible)
+ # OPENAI_COMPATIBLE_BASE_URL: https://litellm.example.com/v1
+ # OPENAI_COMPATIBLE_API_KEY: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }}
+ # OPENAI_COMPATIBLE_MODEL:
+ # both limits are required — set them to the real limits of that model
+ # OPENAI_COMPATIBLE_CONTEXT: "128000"
+ # OPENAI_COMPATIBLE_MAX_OUTPUT: "16384"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e98d66d1d..46cd37f60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.8.3
+
+## Fixes
+- Web purchase redemption now exposes the full checkout product in `didRedeemLink`, tracks `freeTrial_start` once per code, and schedules the active paywall's trial reminders from the checkout timestamp. Notification permission waits no longer block access or drop a late grant; ambiguous or already-elapsed reminders are skipped.
+- Fix multi-page paywalls only reporting the entry page view. `paywall_open` now waits for an in-flight `template_variables` send, so the runtime does not treat a late template payload as a fresh load and drop later `page_view`s.
+- Fix an active paywall not being reopened after its webview process crashes and is recreated. Recovery cancels template work for the old document and sends the open after the replacement loads, only if the same presentation is still active.
+- Fix prices not showing when product/offers are fetched from cache
+- Fix video loading and playing in the background on preloaded paywalls
+- Fix a JSON null in placement parameters or user attributes reaching audience filters as the text `"null"`, so a filter checking whether a field is null never matched.
+
## 2.8.2
## Enhancements
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 323a7bb2d..69792a526 100644
--- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt
+++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt
@@ -47,9 +47,7 @@ object UITestHandler {
"${it.id}"
}.joinToString(separator = ", "),
)
- 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/superwall/build.gradle.kts b/superwall/build.gradle.kts
index 7561d19d4..d0f60db23 100644
--- a/superwall/build.gradle.kts
+++ b/superwall/build.gradle.kts
@@ -85,6 +85,10 @@ android {
buildConfig = true
}
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
+
kotlinOptions {
jvmTarget = "17"
}
diff --git a/superwall/src/androidTest/assets/media-playback.mp4 b/superwall/src/androidTest/assets/media-playback.mp4
new file mode 100644
index 000000000..e5c1ccc74
Binary files /dev/null and b/superwall/src/androidTest/assets/media-playback.mp4 differ
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScriptTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScriptTest.kt
new file mode 100644
index 000000000..4a35d6a38
--- /dev/null
+++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScriptTest.kt
@@ -0,0 +1,133 @@
+package com.superwall.sdk.paywall.view.webview
+
+import android.util.Base64
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+/** Exercises the injected script against real HTML video elements and Chromium playback. */
+@RunWith(AndroidJUnit4::class)
+class MediaPlaybackScriptTest {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private lateinit var webView: WebView
+
+ @Before
+ fun setUp() {
+ val video = instrumentation.context.assets.open("media-playback.mp4").use { it.readBytes() }
+ val source = "data:video/mp4;base64," + Base64.encodeToString(video, Base64.NO_WRAP)
+ val loaded = CountDownLatch(1)
+ instrumentation.runOnMainSync {
+ webView = WebView(instrumentation.targetContext)
+ webView.settings.javaScriptEnabled = true
+ webView.settings.mediaPlaybackRequiresUserGesture = false
+ webView.webViewClient =
+ object : WebViewClient() {
+ override fun onPageFinished(view: WebView, url: String) {
+ loaded.countDown()
+ }
+ }
+ webView.loadDataWithBaseURL(
+ "https://localhost/",
+ """
+
+
+
+
+ """.trimIndent(),
+ "text/html",
+ "UTF-8",
+ null,
+ )
+ }
+ assertTrue("Page did not load", loaded.await(10, TimeUnit.SECONDS))
+ await("document.getElementById('playing').readyState >= 3")
+ }
+
+ @After
+ fun tearDown() {
+ instrumentation.runOnMainSync { webView.destroy() }
+ }
+
+ @Test
+ fun pausesAndResumesOnlyPreviouslyPlayingVideo() {
+ evaluate("document.getElementById('playing').play()")
+ await("document.getElementById('playing').currentTime > 0")
+ evaluate(MediaPlaybackScript.build(false))
+ evaluate(MediaPlaybackScript.build(false)) // Repeated lifecycle callbacks must preserve the set.
+ assertEquals("true", evaluate("document.getElementById('playing').paused"))
+ val pausedTime = evaluate("document.getElementById('playing').currentTime")
+ Thread.sleep(150)
+ assertEquals(pausedTime, evaluate("document.getElementById('playing').currentTime"))
+ evaluate(MediaPlaybackScript.build(true))
+ await("!document.getElementById('playing').paused")
+ await("document.getElementById('playing').currentTime !== $pausedTime")
+ assertEquals("true", evaluate("document.getElementById('manual').paused"))
+ }
+
+ @Test
+ fun blocksLateAutoplayUntilPresentation() {
+ evaluate(MediaPlaybackScript.build(false))
+ evaluate(
+ """
+ window.late = document.getElementById('playing').cloneNode();
+ late.id = 'late';
+ late.autoplay = true;
+ document.body.appendChild(late);
+ """.trimIndent(),
+ )
+ await("window.__swMediaPlayback.suspended.has(late) && late.paused")
+ evaluate(MediaPlaybackScript.build(true))
+ await("!late.paused && late.currentTime > 0")
+ }
+
+ @Test
+ fun doesNotResumeRemovedOrManuallyPausedMedia() {
+ evaluate(MediaPlaybackScript.build(true))
+ evaluate("document.getElementById('playing').play()")
+ await("document.getElementById('playing').currentTime > 0")
+ evaluate("document.getElementById('playing').pause()")
+ evaluate(MediaPlaybackScript.build(false))
+ evaluate(MediaPlaybackScript.build(true))
+ assertEquals("true", evaluate("document.getElementById('playing').paused"))
+
+ evaluate("document.getElementById('playing').play()")
+ await("!document.getElementById('playing').paused")
+ evaluate(MediaPlaybackScript.build(false))
+ evaluate("window.removed = document.getElementById('playing'); removed.remove()")
+ evaluate(MediaPlaybackScript.build(true))
+ assertEquals("true", evaluate("removed.paused"))
+ assertEquals("0", evaluate("window.__swMediaPlayback.suspended.size"))
+ }
+
+ private fun evaluate(script: String): String {
+ val done = CountDownLatch(1)
+ val result = AtomicReference()
+ instrumentation.runOnMainSync {
+ webView.evaluateJavascript(script) {
+ result.set(it)
+ done.countDown()
+ }
+ }
+ assertTrue("JavaScript callback timed out", done.await(5, TimeUnit.SECONDS))
+ return result.get()
+ }
+
+ private fun await(condition: String) {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
+ while (System.nanoTime() < deadline) {
+ if (evaluate(condition) == "true") return
+ Thread.sleep(50)
+ }
+ assertEquals(condition, "true", evaluate(condition))
+ }
+}
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt
new file mode 100644
index 000000000..022c7211a
--- /dev/null
+++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt
@@ -0,0 +1,105 @@
+package com.superwall.sdk.paywall.view.webview.templating
+
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.superwall.sdk.models.config.Config
+import com.superwall.sdk.models.paywall.Paywall
+import com.superwall.sdk.models.product.ProductItem
+import com.superwall.sdk.models.product.ProductVariable
+import com.superwall.sdk.network.JsonFactory
+import com.superwall.sdk.paywall.view.webview.templating.models.Variables
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Uses the real config and template serializers and a real emulator WebView.
+ * The HTML is a minimal price-binding fixture, not the customer's hosted paywall runtime.
+ */
+@RunWith(AndroidJUnit4::class)
+class CachedProductPricesWebViewTest {
+ private val json = JsonFactory.JSON
+
+ @Test
+ fun freshConfigDisplaysOfferPrice() {
+ assertEquals("€23.99", renderOfferPrice(cached = false))
+ }
+
+ @Test
+ fun diskCachedConfigDisplaysOfferPrice() {
+ // Fails on 2.8.2: price data exists but the "offer" binding is absent.
+ assertEquals("€23.99", renderOfferPrice(cached = true))
+ }
+
+ private fun renderOfferPrice(cached: Boolean): String {
+ val item =
+ json.decodeFromString(
+ """{"reference_name":"offer","store_product":{"store":"PLAY_STORE",
+ "product_identifier":"yearly19_3_dc","base_plan_identifier":"p1y",
+ "offer":{"type":"SPECIFIED","offer_identifier":"introprice"}}}""",
+ )
+ val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null)
+ paywall.productItems = listOf(item)
+ val fresh = Config.stub().copy(paywalls = listOf(paywall))
+ val config = if (cached) json.decodeFromString(Config.serializer(), json.encodeToString(Config.serializer(), fresh)) else fresh
+ val product =
+ config.paywalls
+ .single()
+ .productItems
+ .single()
+ val variables =
+ Variables(
+ listOf(ProductVariable(product.name, mapOf("price" to "€23.99"))),
+ emptyMap(),
+ emptyMap(),
+ emptyMap(),
+ )
+ val payload = json.encodeToString(Variables.serializer(), variables)
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ val finished = CountDownLatch(1)
+ val output = AtomicReference()
+ lateinit var webView: WebView
+ instrumentation.runOnMainSync {
+ webView = WebView(instrumentation.targetContext)
+ webView.settings.javaScriptEnabled = true
+ webView.webViewClient =
+ object : WebViewClient() {
+ override fun onPageFinished(
+ view: WebView,
+ url: String?,
+ ) {
+ view.evaluateJavascript("document.getElementById('price').textContent") {
+ output.set(it)
+ finished.countDown()
+ }
+ }
+ }
+ webView.loadDataWithBaseURL(
+ "https://localhost/",
+ """
+
+ """.trimIndent(),
+ "text/html",
+ "UTF-8",
+ null,
+ )
+ }
+ try {
+ assertTrue("WebView did not finish loading", finished.await(20, TimeUnit.SECONDS))
+ return json.parseToJsonElement(output.get()).jsonPrimitive.content
+ } finally {
+ instrumentation.runOnMainSync { webView.destroy() }
+ }
+ }
+}
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 9b3b2228d..fe6b0d72a 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -61,6 +61,7 @@ import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.models.entitlements.TransactionReceipt
import com.superwall.sdk.models.events.EventData
import com.superwall.sdk.models.internal.VendorId
+import com.superwall.sdk.models.paywall.LocalNotification
import com.superwall.sdk.models.paywall.LocalNotificationType
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.product.ProductVariable
@@ -132,10 +133,12 @@ import com.superwall.sdk.utilities.ErrorTracker
import com.superwall.sdk.utilities.dateFormat
import com.superwall.sdk.web.DeepLinkReferrer
import com.superwall.sdk.web.WebPaywallRedeemer
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import com.superwall.sdk.models.serialization.DateSerializer
import kotlinx.serialization.json.ClassDiscriminatorMode
import kotlinx.serialization.json.Json
@@ -1308,6 +1311,24 @@ class DependencyContainer(
}
}
+ override suspend fun scheduleTrialNotifications(notifications: List) {
+ withContext(Dispatchers.Main.immediate) {
+ val paywallView = Superwall.instance.paywallView ?: return@withContext
+ val activity =
+ (paywallView.encapsulatingActivity?.get() ?: activityProvider?.getCurrentActivity())
+ as? SuperwallPaywallActivity ?: return@withContext
+ if (!activity.isFinishing && !activity.isDestroyed) {
+ // Web reminders already use an absolute checkout timestamp, including in sandbox.
+ activity.attemptToScheduleNotifications(
+ notifications,
+ this@DependencyContainer,
+ cancelExisting = false,
+ applySandboxScaling = false,
+ )
+ }
+ }
+ }
+
override fun isPaymentSheetOpen(): Boolean {
// TODO: Track payment sheet state
return false
diff --git a/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt b/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt
index 5b921dec3..b2bc84d6e 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt
@@ -125,20 +125,146 @@ sealed class RedemptionResult {
) : RedemptionResult()
@Serializable
- data class PaywallInfo(
- @SerialName("identifier")
- val identifier: PaywallIdentifier,
- @SerialName("placementName")
- val placementName: String,
- @SerialName("placementParams")
- val placementParams: Map,
- @SerialName("variantId")
- val variantId: VariantId,
- @SerialName("experimentId")
- val experimentId: ExperimentId,
- @SerialName("productIdentifier")
- val productIdentifier: String? = null,
- )
+ class PaywallInfo
+ @JvmOverloads
+ constructor(
+ @SerialName("identifier") val identifier: PaywallIdentifier,
+ @SerialName("placementName") val placementName: String,
+ @SerialName("placementParams") val placementParams: Map,
+ @SerialName("variantId") val variantId: VariantId,
+ @SerialName("experimentId") val experimentId: ExperimentId,
+ @SerialName("productIdentifier") val productIdentifier: String? = null,
+ ) {
+ /** Original checkout variables. Kept outside the constructor to preserve the Kotlin JVM ABI. */
+ @SerialName("product")
+ var product: PaywallProduct? = null
+ private set
+
+ constructor(
+ identifier: PaywallIdentifier,
+ placementName: String,
+ placementParams: Map,
+ variantId: VariantId,
+ experimentId: ExperimentId,
+ productIdentifier: String? = null,
+ product: PaywallProduct?,
+ ) : this(identifier, placementName, placementParams, variantId, experimentId, productIdentifier) {
+ this.product = product
+ }
+
+ // Retain the original copy/copy$default and component signatures for precompiled Kotlin callers.
+ fun copy(
+ identifier: PaywallIdentifier = this.identifier,
+ placementName: String = this.placementName,
+ placementParams: Map = this.placementParams,
+ variantId: VariantId = this.variantId,
+ experimentId: ExperimentId = this.experimentId,
+ productIdentifier: String? = this.productIdentifier,
+ ): PaywallInfo = PaywallInfo(identifier, placementName, placementParams, variantId, experimentId, productIdentifier, product)
+
+ fun copy(
+ identifier: PaywallIdentifier = this.identifier,
+ placementName: String = this.placementName,
+ placementParams: Map = this.placementParams,
+ variantId: VariantId = this.variantId,
+ experimentId: ExperimentId = this.experimentId,
+ productIdentifier: String? = this.productIdentifier,
+ product: PaywallProduct?,
+ ): PaywallInfo = PaywallInfo(identifier, placementName, placementParams, variantId, experimentId, productIdentifier, product)
+
+ operator fun component1(): PaywallIdentifier = identifier
+
+ operator fun component2(): String = placementName
+
+ operator fun component3(): Map = placementParams
+
+ operator fun component4(): VariantId = variantId
+
+ operator fun component5(): ExperimentId = experimentId
+
+ operator fun component6(): String? = productIdentifier
+
+ operator fun component7(): PaywallProduct? = product
+
+ override fun equals(other: Any?): Boolean =
+ other is PaywallInfo &&
+ identifier == other.identifier && placementName == other.placementName &&
+ placementParams == other.placementParams && variantId == other.variantId &&
+ experimentId == other.experimentId && productIdentifier == other.productIdentifier && product == other.product
+
+ override fun hashCode(): Int =
+ listOf(identifier, placementName, placementParams, variantId, experimentId, productIdentifier, product).hashCode()
+
+ override fun toString(): String =
+ "PaywallInfo(identifier=$identifier, placementName=$placementName, placementParams=$placementParams, " +
+ "variantId=$variantId, experimentId=$experimentId, productIdentifier=$productIdentifier, product=$product)"
+
+ @Serializable
+ data class PaywallProduct(
+ @SerialName("identifier")
+ val identifier: String,
+ @SerialName("languageCode")
+ val languageCode: String = "",
+ @SerialName("locale")
+ val locale: String = "",
+ @SerialName("currencyCode")
+ val currencyCode: String = "",
+ @SerialName("currencySymbol")
+ val currencySymbol: String = "",
+ @SerialName("period")
+ val period: String = "",
+ @SerialName("periodly")
+ val periodly: String = "",
+ @SerialName("localizedPeriod")
+ val localizedPeriod: String = "",
+ @SerialName("periodAlt")
+ val periodAlt: String = "",
+ @SerialName("periodDays")
+ val periodDays: Int = 0,
+ @SerialName("periodWeeks")
+ val periodWeeks: Int = 0,
+ @SerialName("periodMonths")
+ val periodMonths: Int = 0,
+ @SerialName("periodYears")
+ val periodYears: Int = 0,
+ @SerialName("rawPrice")
+ val rawPrice: Double = 0.0,
+ @SerialName("price")
+ val price: String = "",
+ @SerialName("dailyPrice")
+ val dailyPrice: String = "",
+ @SerialName("weeklyPrice")
+ val weeklyPrice: String = "",
+ @SerialName("monthlyPrice")
+ val monthlyPrice: String = "",
+ @SerialName("yearlyPrice")
+ val yearlyPrice: String = "",
+ @SerialName("rawTrialPeriodPrice")
+ val rawTrialPeriodPrice: Double = 0.0,
+ @SerialName("trialPeriodPrice")
+ val trialPeriodPrice: String = "",
+ @SerialName("trialPeriodDailyPrice")
+ val trialPeriodDailyPrice: String = "",
+ @SerialName("trialPeriodWeeklyPrice")
+ val trialPeriodWeeklyPrice: String = "",
+ @SerialName("trialPeriodMonthlyPrice")
+ val trialPeriodMonthlyPrice: String = "",
+ @SerialName("trialPeriodYearlyPrice")
+ val trialPeriodYearlyPrice: String = "",
+ @SerialName("trialPeriodDays")
+ val trialPeriodDays: Int = 0,
+ @SerialName("trialPeriodWeeks")
+ val trialPeriodWeeks: Int = 0,
+ @SerialName("trialPeriodMonths")
+ val trialPeriodMonths: Int = 0,
+ @SerialName("trialPeriodYears")
+ val trialPeriodYears: Int = 0,
+ @SerialName("trialPeriodText")
+ val trialPeriodText: String = "",
+ @SerialName("trialPeriodEndDate")
+ val trialPeriodEndDate: String = "",
+ )
+ }
}
@Serializable
diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt
index 650c704e0..a3751549a 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt
@@ -481,8 +481,11 @@ object ProductItemSerializer : KSerializer {
?: throw SerializationException("This class can be loaded only by Json")
val jsonObject = jsonInput.decodeJsonElement().jsonObject
- // Extract fields using the expected names during deserialization
- val name = jsonObject["reference_name"]?.jsonPrimitive?.content ?: ""
+ // API responses use reference_name; serialized cache entries use product.
+ val name =
+ jsonObject["reference_name"]?.jsonPrimitive?.content
+ ?: jsonObject["product"]?.jsonPrimitive?.content
+ ?: ""
val storeProductJsonObject =
jsonObject["store_product"]?.jsonObject
?: throw SerializationException("Missing store_product")
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt
index 447b85380..38caed6a5 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt
@@ -27,6 +27,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
@@ -246,6 +247,11 @@ internal fun Any.toPassableValue(): PassableValue =
private fun JsonElement.toPassableValue(): PassableValue =
when (this) {
+ // Checked before JsonPrimitive, which JsonNull is a kind of. Without this a
+ // JSON null falls past every primitive branch and comes out as the string
+ // "null", so an audience filter comparing the field to null never matches.
+ is JsonNull -> PassableValue.NullValue
+
is JsonObject ->
PassableValue.MapValue(
this.mapValues { (_, value) -> value.toPassableValue() }.toMap(),
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
index c041e571d..d33dddb42 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt
@@ -1007,6 +1007,7 @@ class PaywallView(
private fun recreateWebview() {
val oldWebView = webView
+ oldWebView.messageHandler.resetForWebViewReload()
oldWebView.detach(this)
oldWebView.destroyView()
webView =
@@ -1015,7 +1016,6 @@ class PaywallView(
})
webView.attach(this)
webView.delegate = this
- webView.messageHandler.handle(PaywallMessage.PaywallOpen)
loadWebView()
}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
index 95c7762ad..c8332e5d9 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
@@ -19,6 +19,7 @@ import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
+import android.os.SystemClock
import android.os.Looper
import android.view.View
import android.view.ViewGroup
@@ -63,10 +64,11 @@ import com.superwall.sdk.utilities.withErrorTracking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import java.lang.ref.WeakReference
import java.util.UUID
import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
+import kotlin.coroutines.resumeWithException
class SuperwallPaywallActivity : AppCompatActivity() {
companion object {
@@ -788,6 +790,7 @@ class SuperwallPaywallActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
val paywallVc = paywallView() ?: return
+ paywallVc.webView.onResume()
if (isBottomSheetView || isPopupView) {
setTransparentBackground()
}
@@ -801,6 +804,7 @@ class SuperwallPaywallActivity : AppCompatActivity() {
super.onPause()
val paywallVc = paywallView() ?: return
+ paywallVc.webView.onPause()
mainScope.launch {
paywallVc.beforeOnDestroy(forceCleanup = isFinishing)
}
@@ -825,6 +829,7 @@ class SuperwallPaywallActivity : AppCompatActivity() {
}
override fun onDestroy() {
+ notificationPermissionCallback?.onPermissionResult(false)
super.onDestroy()
val content = contentView as? ViewGroup?
@@ -901,30 +906,76 @@ class SuperwallPaywallActivity : AppCompatActivity() {
notifications: List,
factory: DeviceHelperFactory,
cancelExisting: Boolean = false,
- ) = suspendCoroutine { continuation ->
+ ) = attemptToScheduleNotifications(notifications, factory, cancelExisting, applySandboxScaling = true)
+
+ internal suspend fun attemptToScheduleNotifications(
+ notifications: List,
+ factory: DeviceHelperFactory,
+ cancelExisting: Boolean,
+ applySandboxScaling: Boolean,
+ ) = suspendCancellableCoroutine { continuation ->
if (notifications.isEmpty()) {
continuation.resume(Unit) // Resume immediately as there's nothing to schedule
- return@suspendCoroutine
+ return@suspendCancellableCoroutine
}
createNotificationChannel()
-
- notificationPermissionCallback =
+ val permissionRequestedAt = SystemClock.elapsedRealtime()
+ // A replacement request must release the previous waiter, too.
+ notificationPermissionCallback?.onPermissionResult(false)
+ val callback =
object : NotificationPermissionCallback {
override fun onPermissionResult(granted: Boolean) {
- if (granted) {
- NotificationScheduler.scheduleNotifications(
- notifications = notifications,
- factory = factory,
- context = this@SuperwallPaywallActivity,
- cancelExisting = cancelExisting,
- )
+ if (notificationPermissionCallback === this) notificationPermissionCallback = null
+ try {
+ if (granted) {
+ scheduleGrantedNotifications(
+ notifications,
+ factory,
+ cancelExisting,
+ applySandboxScaling,
+ permissionRequestedAt,
+ )
+ }
+ } catch (e: Exception) {
+ if (continuation.isActive) continuation.resumeWithException(e)
+ return
}
- continuation.resume(Unit) // Resume coroutine after processing
+ if (continuation.isActive) continuation.resume(Unit)
}
}
+ notificationPermissionCallback = callback
+ try {
+ checkAndRequestNotificationPermissions(this, callback)
+ } catch (e: Exception) {
+ if (notificationPermissionCallback === callback) notificationPermissionCallback = null
+ if (continuation.isActive) continuation.resumeWithException(e)
+ }
+ }
- checkAndRequestNotificationPermissions(this, notificationPermissionCallback!!)
+ private fun scheduleGrantedNotifications(
+ notifications: List,
+ factory: DeviceHelperFactory,
+ cancelExisting: Boolean,
+ applySandboxScaling: Boolean,
+ permissionRequestedAt: Long,
+ ) {
+ // Web delays are anchored to checkout, so permission wait must not shift them.
+ val readyNotifications =
+ if (applySandboxScaling) {
+ notifications
+ } else {
+ val elapsed = SystemClock.elapsedRealtime() - permissionRequestedAt
+ notifications.mapNotNull { it.copy(delay = it.delay - elapsed).takeIf { reminder -> reminder.delay > 0 } }
+ }
+ if (readyNotifications.isEmpty()) return
+ NotificationScheduler.scheduleNotifications(
+ notifications = readyNotifications,
+ factory = factory,
+ context = this,
+ cancelExisting = cancelExisting,
+ applySandboxScaling = applySandboxScaling,
+ )
}
private fun createNotificationChannel() {
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScript.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScript.kt
new file mode 100644
index 000000000..2310bdebd
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScript.kt
@@ -0,0 +1,41 @@
+package com.superwall.sdk.paywall.view.webview
+
+/** Keeps hidden paywall media paused without suspending JavaScript needed for preloading. */
+internal object MediaPlaybackScript {
+ fun build(allowed: Boolean): String =
+ """
+ (() => {
+ const key = '__swMediaPlayback';
+ if (!window[key]) {
+ const state = { allowed: false, suspended: new Set() };
+ state.pause = media => {
+ if (!media.paused && !media.ended) {
+ state.suspended.add(media);
+ media.pause();
+ }
+ };
+ // Capture also catches autoplay and media inserted after the initial scan.
+ document.addEventListener('play', event => {
+ if (!state.allowed && event.target instanceof HTMLMediaElement) {
+ state.pause(event.target);
+ }
+ }, true);
+ window[key] = state;
+ }
+ const state = window[key];
+ state.allowed = $allowed;
+ if (!state.allowed) {
+ document.querySelectorAll('video, audio').forEach(state.pause);
+ } else {
+ const suspended = Array.from(state.suspended);
+ state.suspended.clear();
+ suspended.forEach(media => {
+ if (media.isConnected && !media.ended) {
+ const result = media.play();
+ if (result) result.catch(() => {});
+ }
+ });
+ }
+ })();
+ """.trimIndent()
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt
index 719aa167d..3844b670e 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt
@@ -14,6 +14,10 @@ interface PaywallWebUI {
fun enableBackgroundRendering()
+ fun onPause() {}
+
+ fun onResume() {}
+
fun scrollBy(
x: Int,
y: Int,
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
index 7760e4e58..c4459836b 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
@@ -181,6 +181,52 @@ class SWWebView(
private var lastLoadedUrl: String? = null
private var loadRetryCount = 0
+ private var hostPaused = false
+ private var viewDestroyed = false
+ // View callbacks can run from the superclass constructor, before Kotlin initializers.
+ // The JVM default (false) keeps them from evaluating JS until this is set to true, so it
+ // must stay a field with an initializer rather than being inlined.
+ private var mediaLifecycleReady = true
+
+ private fun updateMediaPlayback() {
+ if (!mediaLifecycleReady || viewDestroyed) return
+ val allowed = !hostPaused && isAttachedToWindow && isShown && windowVisibility == View.VISIBLE
+ evaluateJavascript(MediaPlaybackScript.build(allowed), null)
+ }
+
+ override fun onPause() {
+ hostPaused = true
+ updateMediaPlayback()
+ super.onPause()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ hostPaused = false
+ updateMediaPlayback()
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ // A cached view can be attached to a different host, including an embedded one.
+ onResume()
+ }
+
+ override fun onDetachedFromWindow() {
+ if (!viewDestroyed) evaluateJavascript(MediaPlaybackScript.build(false), null)
+ super.onDetachedFromWindow()
+ }
+
+ override fun onWindowVisibilityChanged(visibility: Int) {
+ super.onWindowVisibilityChanged(visibility)
+ updateMediaPlayback()
+ }
+
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
+ super.onVisibilityChanged(changedView, visibility)
+ updateMediaPlayback()
+ }
+
// The device preload script seeds `window.__SW_DEVICE_PRELOAD__` as soon as
// the page starts loading, so translated paywalls render in the device locale
// on first paint instead of waiting for the `template_variables` message. The
@@ -196,6 +242,7 @@ class SWWebView(
}
private val onPageStartedPreloadHook: (WebView) -> Unit = { view ->
+ updateMediaPlayback()
currentDeviceLocale()?.let { locale ->
view.evaluateJavascript(DevicePreloadScript.build(locale), null)
}
@@ -459,6 +506,8 @@ class SWWebView(
}
is WebviewClientEvent.OnPageFinished -> {
+ // Reinstall after navigation in case the early injection was lost.
+ updateMediaPlayback()
// The client records page-level failures synchronously on the
// WebViewClient callback thread, so this can't miss an error
// whose async OnError event hasn't been processed yet.
@@ -574,6 +623,7 @@ class SWWebView(
}
override fun destroy() {
+ viewDestroyed = true
onScrollChangeListener = null
super.destroy()
}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
index 1446d9f89..a8924965a 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
@@ -31,7 +31,11 @@ import com.superwall.sdk.storage.core_data.convertFromJsonElement
import com.superwall.sdk.storage.core_data.convertToJsonElement
import com.superwall.sdk.utilities.withErrorTracking
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -41,10 +45,14 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import java.net.URI
import java.util.Date
-import java.util.LinkedList
import java.util.Queue
+import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.resume
+// Bound the wait so a hung product/attribute fetch cannot silence paywall_open.
+// The in-flight template send is not cancelled; it still posts and marks Ready.
+internal const val TEMPLATE_OPEN_WAIT_MS = 10_000L
+
interface PaywallStateDelegate {
val state: PaywallViewState
@@ -97,7 +105,106 @@ class PaywallMessageHandler(
}
var messageHandler: PaywallMessageHandlerDelegate? = null
- private val queue: Queue = LinkedList()
+
+ private data class PendingLifecycle(
+ val message: PaywallMessage,
+ val shouldSend: () -> Boolean = { true },
+ )
+
+ // template_variables after paywall_open is treated as a fresh load by the
+ // runtime, which then drops later page_views. Template construction is slow,
+ // so open/close wait until every in-flight template send has posted.
+ // Other events do not wait.
+ private val lifecycleLock = Any()
+ private val inFlightTemplateSends = mutableSetOf()
+ private val queue: Queue = ConcurrentLinkedQueue()
+
+ private fun launchTemplateSend(block: suspend () -> Unit) {
+ synchronized(lifecycleLock) {
+ launchTemplateSendLocked(block)
+ }
+ }
+
+ // Caller holds lifecycleLock. Register the job before it starts so a
+ // concurrent open cannot observe "loaded, nothing in flight."
+ private fun launchTemplateSendLocked(block: suspend () -> Unit) {
+ val job =
+ ioScope.launch(start = CoroutineStart.LAZY) {
+ block()
+ }
+ inFlightTemplateSends.add(job)
+ job.invokeOnCompletion {
+ synchronized(lifecycleLock) {
+ inFlightTemplateSends.remove(job)
+ }
+ }
+ job.start()
+ }
+
+ private suspend fun awaitInFlightTemplates() {
+ val completed =
+ withTimeoutOrNull(TEMPLATE_OPEN_WAIT_MS) {
+ while (true) {
+ val jobs = synchronized(lifecycleLock) { inFlightTemplateSends.toList() }
+ if (jobs.isEmpty()) return@withTimeoutOrNull Unit
+ jobs.forEach { it.join() }
+ }
+ }
+ if (completed == null) {
+ Logger.debug(
+ LogLevel.warn,
+ LogScope.paywallView,
+ "Timed out waiting for template_variables; sending lifecycle anyway",
+ )
+ }
+ }
+
+ private fun sendLifecycleWhenReady(pending: PendingLifecycle) {
+ synchronized(lifecycleLock) {
+ if (messageHandler?.state?.paywall?.paywalljsVersion == null) {
+ queue.offer(pending)
+ return
+ }
+ }
+ ioScope.launch {
+ awaitInFlightTemplates()
+ if (!pending.shouldSend()) return@launch
+ // Reload nulls the version; an open that was already in flight must not
+ // land on the replacement document before it is ready.
+ val paywall = messageHandler?.state?.paywall ?: return@launch
+ if (paywall.paywalljsVersion == null) return@launch
+ val eventName =
+ if (pending.message is PaywallMessage.PaywallOpen) {
+ SuperwallEvents.PaywallOpen.rawName
+ } else {
+ SuperwallEvents.PaywallClose.rawName
+ }
+ pass(eventName = eventName, paywall = paywall)
+ }
+ }
+
+ // Called on the old handler before the WebView is replaced. Cancel work for
+ // the old document and restore an open only if that presentation is still up.
+ internal fun resetForWebViewReload() {
+ synchronized(lifecycleLock) {
+ inFlightTemplateSends.toList().forEach { it.cancel() }
+ inFlightTemplateSends.clear()
+ queue.clear()
+ val state = messageHandler?.state
+ messageHandler?.updateState(PaywallViewState.Updates.SetPaywallJsVersion(null))
+ if (state?.isPresented == true && !state.closedForBackground) {
+ val lastOpen = state.lastOpen
+ queue.offer(
+ PendingLifecycle(PaywallMessage.PaywallOpen) {
+ val current = messageHandler?.state
+ current?.isPresented == true &&
+ !current.closedForBackground &&
+ current.lastOpen === lastOpen
+ },
+ )
+ }
+ }
+ }
@JavascriptInterface
fun postMessage(message: String) {
@@ -140,19 +247,23 @@ class PaywallMessageHandler(
) { "!! PaywallMessageHandler: Paywall: $paywall, delegeate: $messageHandler" }
when (message) {
is PaywallMessage.TemplateParamsAndUserAttributes ->
- ioScope.launch { passTemplatesToWebView(paywall) }
+ launchTemplateSend { passTemplatesToWebView(paywall) }
is PaywallMessage.OnReady -> {
- messageHandler?.updateState(
- PaywallViewState.Updates.SetPaywallJsVersion(message.paywallJsVersion),
- )
val loadedAt = Date()
Logger.debug(
LogLevel.debug,
LogScope.superwallCore,
"!! PaywallMessageHandler: Ready !!",
)
- ioScope.launch { didLoadWebView(paywall, loadedAt) }
+ synchronized(lifecycleLock) {
+ messageHandler?.updateState(
+ PaywallViewState.Updates.SetPaywallJsVersion(message.paywallJsVersion),
+ )
+ launchTemplateSendLocked {
+ didLoadWebView(paywall, loadedAt)
+ }
+ }
}
is PaywallMessage.Close -> {
@@ -177,26 +288,9 @@ class PaywallMessageHandler(
shouldDismiss = message.shouldDismiss,
)
- is PaywallMessage.PaywallOpen -> {
- if (messageHandler?.state?.paywall?.paywalljsVersion == null) {
- queue.offer(message)
- } else {
- ioScope.launch {
- pass(eventName = SuperwallEvents.PaywallOpen.rawName, paywall = paywall)
- }
- }
- }
-
- is PaywallMessage.PaywallClose -> {
- if (messageHandler?.state?.paywall?.paywalljsVersion == null) {
- queue.offer(message)
- } else {
- ioScope.launch {
- val eventName = SuperwallEvents.PaywallClose.rawName
- pass(eventName = eventName, paywall = paywall)
- }
- }
- }
+ is PaywallMessage.PaywallOpen,
+ is PaywallMessage.PaywallClose,
+ -> sendLifecycleWhenReady(PendingLifecycle(message))
is PaywallMessage.BackButtonPressed ->
ioScope.launch {
@@ -376,6 +470,7 @@ class PaywallMessageHandler(
)
withContext(Dispatchers.Main) {
+ currentCoroutineContext().ensureActive()
messageHandler?.evaluate(templateScript) { error ->
if (error != null) {
Logger.debug(
@@ -451,7 +546,10 @@ class PaywallMessageHandler(
message = { "Posting Message" },
)
- mainScope.launch {
+ // Await the post so an in-flight paywall_open that joins this job
+ // cannot overtake the templates.
+ withContext(Dispatchers.Main) {
+ currentCoroutineContext().ensureActive()
messageHandler?.evaluate(scriptSrc) { error ->
if (error != null) {
Logger.debug(
@@ -485,11 +583,14 @@ class PaywallMessageHandler(
}
private fun flushPendingMessagesInternal() {
- if (queue.isEmpty()) return
-
- val pending = queue.toList()
- queue.clear()
- pending.forEach { handle(it) }
+ // Snapshot first. sendLifecycleWhenReady re-queues when the webview
+ // is not ready; draining until empty would spin on the main thread.
+ val pending = ArrayList()
+ while (true) {
+ val next = queue.poll() ?: break
+ pending.add(next)
+ }
+ pending.forEach { sendLifecycleWhenReady(it) }
}
private fun openUrl(
diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt
index d7f913eed..2b3287dd2 100644
--- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt
+++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt
@@ -322,6 +322,12 @@ internal object PurchasingProductdIds : Storable> {
get() = SetSerializer(String.serializer())
}
+internal object TrackedWebTrialCodes : Storable> {
+ override val key = "store.trackedWebTrialCodes"
+ override val directory = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS
+ override val serializer = SetSerializer(String.serializer())
+}
+
internal object LatestRedemptionResponse : Storable {
override val key: String
get() = "store.latestRedemptionResponse"
diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt
index 33a78a9d6..5e9b07702 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt
@@ -45,6 +45,7 @@ internal class NotificationScheduler {
factory: DeviceHelperFactory,
context: Context,
cancelExisting: Boolean = false,
+ applySandboxScaling: Boolean = true,
) {
val workManager = WorkManager.getInstance(context)
IOScope().launch {
@@ -67,7 +68,7 @@ internal class NotificationScheduler {
var delay = notification.delay // delay in milliseconds
val isSandbox = factory.makeIsSandbox()
- if (isSandbox) {
+ if (isSandbox && applySandboxScaling) {
delay = delay / 24 / 60
}
diff --git a/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt
new file mode 100644
index 000000000..cf5f5fa97
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt
@@ -0,0 +1,86 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import com.superwall.sdk.store.abstractions.product.StoreProductType
+import com.superwall.sdk.store.abstractions.product.SubscriptionPeriod
+import org.threeten.bp.DateTimeException
+import org.threeten.bp.LocalDate
+import org.threeten.bp.OffsetDateTime
+import org.threeten.bp.ZoneOffset
+import org.threeten.bp.format.DateTimeParseException
+import java.math.BigDecimal
+import java.util.Date
+
+/** Uses the checkout snapshot for trial analytics, including its original prices and end date. */
+internal class RedemptionStoreProduct(
+ private val product: PaywallProduct,
+) : StoreProductType {
+ override val fullIdentifier = product.identifier
+ override val productIdentifier = product.identifier
+ override val price = BigDecimal.valueOf(product.rawPrice)
+ override val localizedPrice = product.price
+ override val localizedSubscriptionPeriod = product.localizedPeriod
+ override val period = product.period
+ override val periodly = product.periodly
+ override val periodDays = product.periodDays
+ override val periodWeeks = product.periodWeeks
+ override val periodMonths = product.periodMonths
+ override val periodYears = product.periodYears
+ override val periodDaysString = periodDays.toString()
+ override val periodWeeksString = periodWeeks.toString()
+ override val periodMonthsString = periodMonths.toString()
+ override val periodYearsString = periodYears.toString()
+ override val dailyPrice = product.dailyPrice
+ override val weeklyPrice = product.weeklyPrice
+ override val monthlyPrice = product.monthlyPrice
+ override val yearlyPrice = product.yearlyPrice
+ override val hasFreeTrial = product.trialPeriodDays > 0
+ override val localizedTrialPeriodPrice = product.trialPeriodPrice
+ override val trialPeriodPrice = BigDecimal.valueOf(product.rawTrialPeriodPrice)
+ override val trialPeriodEndDateString = product.trialPeriodEndDate
+ override val trialPeriodEndDate: Date? by lazy {
+ // Checkout snapshots may contain either an ISO timestamp or a calendar date.
+ val end = product.trialPeriodEndDate
+ try {
+ val instant =
+ try {
+ OffsetDateTime.parse(end).toInstant()
+ } catch (_: DateTimeParseException) {
+ LocalDate.parse(end).atStartOfDay().toInstant(ZoneOffset.UTC)
+ }
+ Date(instant.toEpochMilli())
+ } catch (_: DateTimeException) {
+ null
+ } catch (_: ArithmeticException) {
+ null
+ }
+ }
+ override val trialPeriodDays = product.trialPeriodDays
+ override val trialPeriodWeeks = product.trialPeriodWeeks
+ override val trialPeriodMonths = product.trialPeriodMonths
+ override val trialPeriodYears = product.trialPeriodYears
+ override val trialPeriodDaysString = trialPeriodDays.toString()
+ override val trialPeriodWeeksString = trialPeriodWeeks.toString()
+ override val trialPeriodMonthsString = trialPeriodMonths.toString()
+ override val trialPeriodYearsString = trialPeriodYears.toString()
+ override val trialPeriodText = product.trialPeriodText
+ override val locale = product.locale
+ override val languageCode = product.languageCode
+ override val currencyCode = product.currencyCode
+ override val currencySymbol = product.currencySymbol
+ override val regionCode: String? = null
+ override val subscriptionPeriod =
+ product.periodDays.takeIf { it > 0 }?.let { SubscriptionPeriod(it, SubscriptionPeriod.Unit.day).normalized() }
+ override val productType = if (subscriptionPeriod != null) "subs" else "inapp"
+
+ override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String =
+ when (unit) {
+ SubscriptionPeriod.Unit.day -> product.trialPeriodDailyPrice
+ SubscriptionPeriod.Unit.week -> product.trialPeriodWeeklyPrice
+ SubscriptionPeriod.Unit.month -> product.trialPeriodMonthlyPrice
+ SubscriptionPeriod.Unit.year -> product.trialPeriodYearlyPrice
+ }
+
+ override val attributes: Map
+ get() = super.attributes + ("periodAlt" to product.periodAlt)
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt
index b67751ef3..d9b5d6e1b 100644
--- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt
@@ -22,24 +22,35 @@ import com.superwall.sdk.models.internal.ErrorInfo
import com.superwall.sdk.models.internal.RedemptionOwnership
import com.superwall.sdk.models.internal.RedemptionOwnershipType
import com.superwall.sdk.models.internal.RedemptionResult
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
import com.superwall.sdk.models.internal.UserId
+import com.superwall.sdk.models.paywall.LocalNotification
+import com.superwall.sdk.models.paywall.LocalNotificationType
import com.superwall.sdk.network.Network
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.storage.LastWebEntitlementsFetchDate
import com.superwall.sdk.storage.LatestRedemptionResponse
import com.superwall.sdk.storage.LatestWebCustomerInfo
import com.superwall.sdk.storage.Storage
+import com.superwall.sdk.storage.TrackedWebTrialCodes
+import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.utilities.withErrorTracking
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
+internal const val WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS = 30_000L
+
@Suppress("EXPOSED_PARAMETER_TYPE")
class WebPaywallRedeemer(
private val context: Context,
@@ -90,10 +101,17 @@ class WebPaywallRedeemer(
fun closePaywallIfExists()
fun isPaymentSheetOpen(): Boolean
+
+ suspend fun scheduleTrialNotifications(notifications: List) {}
+
+ fun currentTimeMillis(): Long = System.currentTimeMillis()
}
private var pollingJob: Job? = null
private var redemptionJob: Job? = null
+ // Code redemptions launch independently on IOScope; hold this across track() so two
+ // overlapping same-code calls cannot both observe an empty set and emit twice.
+ private val trialTrackingMutex = Mutex()
private suspend fun track(event: Trackable) = factory.track(event)
@@ -223,6 +241,24 @@ class WebPaywallRedeemer(
redemption,
),
)
+ // Apply access before trial handling can wait for notification permission.
+ factory.internallySetSubscriptionStatus(
+ SubscriptionStatus.Active(
+ it.customerInfo
+ ?.entitlements
+ ?.filter { it.isActive }
+ ?.toSet()
+ .orEmpty() +
+ factory.getActiveDeviceEntitlements(),
+ ),
+ )
+ val codeResult =
+ if (redemption is RedeemType.Code) {
+ it.codes.firstOrNull { result -> result.code == redemption.code }
+ ?: RedemptionResult.Error(redemption.code, ErrorInfo("Redemption failed, code not returned"))
+ } else {
+ null
+ }
when (redemption) {
is RedeemType.Code -> {
Logger.debug(
@@ -238,22 +274,9 @@ class WebPaywallRedeemer(
),
)
- val result =
- if (it.codes.any { it.code == redemption.code }) {
- it.codes
- } else {
- listOf(
- RedemptionResult.Error(
- code =
- (redemption as? RedeemType.Code?)?.code
- ?: "",
- error = ErrorInfo("Redemption failed, code not returned"),
- ),
- )
- }
- val redemptionResultForCode =
- result.firstOrNull { it.code == redemption.code }
- if (redemptionResultForCode != null) {
+ if (codeResult != null) {
+ // Restoration can dismiss the paywall too, so finish trial work first.
+ handleTrialRedemption(codeResult)
if (factory.isPaywallVisible() && !factory.isPaymentSheetOpen()) {
if (it.customerInfo?.entitlements?.map { it.id }?.containsAll(
factory.currentPaywallEntitlements().map { it.id },
@@ -271,23 +294,9 @@ class WebPaywallRedeemer(
// NO-OP
}
}
- factory.internallySetSubscriptionStatus(
- SubscriptionStatus.Active(
- (
- it.customerInfo
- ?.entitlements
- ?.filter { it.isActive }
- ?.toSet() ?: emptySet()
- ) +
- factory.getActiveDeviceEntitlements(),
- ),
- )
- if (redemption is RedeemType.Code) {
+ if (codeResult != null) {
factory.closePaywallIfExists()
- val res = it.codes.first { it.code == redemption.code }
- factory.didRedeemLink(
- res,
- )
+ factory.didRedeemLink(codeResult)
}
// Notify the delegate that the redemption succeeded, unless the code has not been redeemed
@@ -329,6 +338,61 @@ class WebPaywallRedeemer(
startPolling()
}
+ private suspend fun handleTrialRedemption(result: RedemptionResult) {
+ val product = (result as? RedemptionResult.Success)?.redemptionInfo?.paywallInfo?.product ?: return
+ if (product.trialPeriodDays <= 0 || !factory.isPaywallVisible()) return
+ // Match iOS: eligibility and attribution use the active paywall's presentation snapshot;
+ // purchased product details come from the web checkout response.
+ val paywallInfo = factory.getPaywallInfo()
+ if (!paywallInfo.isFreeTrialAvailable) return
+
+ attemptTrialSideEffect("track web free trial start") {
+ trialTrackingMutex.withLock {
+ val trackedCodes = storage.read(TrackedWebTrialCodes).orEmpty()
+ if (result.code !in trackedCodes) {
+ track(InternalSuperwallEvent.FreeTrialStart(paywallInfo, StoreProduct(RedemptionStoreProduct(product))))
+ storage.write(TrackedWebTrialCodes, trackedCodes + result.code)
+ }
+ }
+ }
+ val reminders = trialReminders(paywallInfo, product)
+ if (reminders.isEmpty()) return
+ attemptTrialSideEffect("schedule web trial notifications") {
+ withTimeoutOrNull(WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS) {
+ factory.scheduleTrialNotifications(reminders)
+ }
+ }
+ }
+
+ private fun trialReminders(
+ paywallInfo: PaywallInfo,
+ product: PaywallProduct,
+ ): List =
+ paywallInfo.localNotifications.mapNotNull { notification ->
+ if (notification.type != LocalNotificationType.TrialStarted) return@mapNotNull null
+ webTrialReminderDelay(product, notification.delay, factory.currentTimeMillis())?.let { delay ->
+ notification.copy(id = "${paywallInfo.identifier}_${notification.type.raw}", delay = delay)
+ }
+ }
+
+ private suspend fun attemptTrialSideEffect(
+ description: String,
+ block: suspend () -> Unit,
+ ) {
+ try {
+ block()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.webEntitlements,
+ message = "Failed to $description",
+ error = e,
+ )
+ }
+ }
+
suspend fun checkForWebEntitlements(
userId: UserId?,
deviceId: DeviceVendorId,
diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebTrialReminder.kt b/superwall/src/main/java/com/superwall/sdk/web/WebTrialReminder.kt
new file mode 100644
index 000000000..c9a951bb0
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/web/WebTrialReminder.kt
@@ -0,0 +1,27 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import org.threeten.bp.DateTimeException
+import org.threeten.bp.Duration
+import org.threeten.bp.Instant
+import org.threeten.bp.OffsetDateTime
+
+/** A config delay is relative to checkout, whereas WorkManager needs a delay relative to redemption. */
+internal fun webTrialReminderDelay(
+ product: PaywallProduct,
+ configuredDelay: Long,
+ now: Long,
+): Long? {
+ if (product.trialPeriodDays <= 0 || configuredDelay <= 0) return null
+ // Display strings and calendar dates have no unambiguous instant. Never guess their timezone.
+ return try {
+ val end = OffsetDateTime.parse(product.trialPeriodEndDate).toInstant()
+ val target = end.minus(Duration.ofDays(product.trialPeriodDays.toLong())).plusMillis(configuredDelay)
+ val current = Instant.ofEpochMilli(now)
+ if (target <= current || target >= end) null else Duration.between(current, target).toMillis()
+ } catch (_: DateTimeException) {
+ null
+ } catch (_: ArithmeticException) {
+ null
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/JsonElementToPassableValueTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/JsonElementToPassableValueTest.kt
index 0acec103a..e5db9c248 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/JsonElementToPassableValueTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/JsonElementToPassableValueTest.kt
@@ -64,6 +64,14 @@ class JsonElementToPassableValueTest {
assertEquals(false, (passableFalseValue as PassableValue.BoolValue).value)
}
+ @Test
+ fun `test JsonNull conversion to PassableValue`() {
+ // JsonNull is a JsonPrimitive, so without an explicit branch it falls past
+ // every primitive check and comes out as the string "null". An audience
+ // filter asking `field == null` would then never match.
+ assertTrue(JsonNull.toPassableValue() is PassableValue.NullValue)
+ }
+
@Test
fun `test JsonObject conversion to PassableValue`() {
val jsonObject =
@@ -94,8 +102,7 @@ class JsonElementToPassableValueTest {
assertTrue(resultMap["boolean"] is PassableValue.BoolValue)
assertEquals(true, (resultMap["boolean"] as PassableValue.BoolValue).value)
- assertTrue(resultMap["null"] is PassableValue.StringValue)
- assertEquals("null", (resultMap["null"] as PassableValue.StringValue).value)
+ assertTrue(resultMap["null"] is PassableValue.NullValue)
}
@Test
@@ -128,8 +135,7 @@ class JsonElementToPassableValueTest {
assertTrue(resultList[3] is PassableValue.BoolValue)
assertEquals(true, (resultList[3] as PassableValue.BoolValue).value)
- assertTrue(resultList[4] is PassableValue.StringValue)
- assertEquals("null", (resultList[4] as PassableValue.StringValue).value)
+ assertTrue(resultList[4] is PassableValue.NullValue)
}
@Test
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt
new file mode 100644
index 000000000..701ddac56
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt
@@ -0,0 +1,208 @@
+package com.superwall.sdk.paywall.request
+
+import TemplateLogic
+import com.superwall.sdk.config.PaywallPreload
+import com.superwall.sdk.dependencies.VariablesFactory
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.IOScope
+import com.superwall.sdk.models.config.Config
+import com.superwall.sdk.models.customer.CustomerInfo
+import com.superwall.sdk.models.paywall.Paywall
+import com.superwall.sdk.models.product.ProductItem
+import com.superwall.sdk.network.JsonFactory
+import com.superwall.sdk.network.Network
+import com.superwall.sdk.paywall.view.webview.templating.models.Variables
+import com.superwall.sdk.store.StoreManager
+import com.superwall.sdk.store.abstractions.product.StoreProduct
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Regression coverage for product reference names lost from cached config in 2.8.2. */
+class CachedProductPricesReproductionTest {
+ private val json = JsonFactory.JSON
+ private val names = listOf("primary", "secondary", "offer")
+ private val ids =
+ listOf("yearly23:yearly23-base:yearly23-trial", "monthly23:monthly23-base:monthly23-trial", "yearly19_3_dc:p1y:introprice")
+ private val prices = listOf("€39.99", "€4.99", "€23.99")
+
+ private fun freshConfig(): Config {
+ val items =
+ ids.mapIndexed { index, id ->
+ val parts = id.split(":")
+ json.decodeFromString(
+ """{"reference_name":"${names[index]}","sw_composite_product_id":"$id",
+ "store_product":{"store":"PLAY_STORE","product_identifier":"${parts[0]}",
+ "base_plan_identifier":"${parts[1]}","offer":{"type":"SPECIFIED","offer_identifier":"${parts[2]}"}}}""",
+ )
+ }
+ val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null)
+ paywall.productItems = items
+ return Config.stub().copy(paywalls = listOf(paywall))
+ }
+
+ private fun restoredConfig(fresh: Config): Config =
+ json.decodeFromString(Config.serializer(), json.encodeToString(Config.serializer(), fresh))
+
+ @Test
+ fun `product reference name survives serialization`() {
+ val original =
+ freshConfig()
+ .paywalls
+ .single()
+ .productItems
+ .last()
+ val restored = json.decodeFromString(ProductItem.serializer(), json.encodeToString(ProductItem.serializer(), original))
+ assertEquals(original.fullProductId, restored.fullProductId)
+ assertEquals("Product cache round-trip must preserve the template reference", "offer", restored.name)
+ }
+
+ @Test
+ fun `whole config round trip preserves all product references`() {
+ val restored = restoredConfig(freshConfig())
+ assertEquals(
+ ids,
+ restored.paywalls
+ .single()
+ .productItems
+ .map { it.fullProductId },
+ )
+ assertEquals(
+ names,
+ restored.paywalls
+ .single()
+ .productItems
+ .map { it.name },
+ )
+ }
+
+ @Test
+ fun `fresh config supplies named prices to webview`() =
+ runTest {
+ val fresh = freshConfig()
+ val harness = Harness(fresh)
+ assertNamedPrices(harness.templates(harness.load()))
+ }
+
+ @Test
+ fun `same build refresh repairs prices after loading disk cached config`() =
+ runTest {
+ val fresh = freshConfig()
+ val restored = restoredConfig(fresh)
+ val harness = Harness(restored)
+ val cached = harness.load()
+ val before = harness.templates(cached)
+ val loadedPrices =
+ before[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray.map {
+ it.jsonObject.values
+ .single()
+ .jsonObject["price"]!!
+ .jsonPrimitive.content
+ }
+ assertEquals("Product prices must be loaded before the config refresh", prices, loadedPrices)
+ println("Product bindings before refresh: ${before[0]}")
+ harness.config = fresh
+ // The same production diff + invalidation used by ConfigState.RefreshConfig in 2.8.2.
+ val changed = PaywallPreload.changedPaywallIds(restored, fresh)
+ assertTrue("Fixture must keep the same paywall build", changed.isEmpty())
+ harness.manager.removeCachedPaywalls(changed)
+ assertNamedPrices(harness.templates(harness.load()))
+ }
+
+ @Test
+ fun `281 full request cache reset recovers named prices`() =
+ runTest {
+ val fresh = freshConfig()
+ val harness = Harness(restoredConfig(fresh))
+ harness.load()
+ harness.config = fresh
+ // 2.8.1 invalidated every request entry on config refresh.
+ harness.manager.resetCache()
+ assertNamedPrices(harness.templates(harness.load()))
+ }
+
+ private fun assertNamedPrices(templates: JsonArray) {
+ val productRefs = templates[0].jsonObject["products"]!!.jsonArray.map { it.jsonObject["product"]!!.jsonPrimitive.content }
+ val variables = templates[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray
+ assertEquals("WebView product references must be addressable by name", names, productRefs)
+ names.forEachIndexed { index, name ->
+ assertEquals(
+ prices[index],
+ variables[index]
+ .jsonObject[name]!!
+ .jsonObject["price"]!!
+ .jsonPrimitive.content,
+ )
+ }
+ }
+
+ private inner class Harness(
+ var config: Config,
+ ) {
+ private val store = StoreManager(mockk(relaxed = true), mockk(relaxed = true), { mockk(relaxed = true) }, track = {})
+ private val factory =
+ mockk {
+ every { makeDeviceInfo() } returns mockk { every { locale } returns "en_GB" }
+ every { makeStaticPaywall(any(), any()) } answers { config.paywalls.single() }
+ every { activePaywallId() } returns null
+ every { currentCustomerInfo() } returns CustomerInfo.empty()
+ }
+ val manager =
+ PaywallRequestManager(
+ store,
+ mockk(),
+ factory,
+ IOScope(Dispatchers.Unconfined),
+ track = {},
+ getGlobalOverrides = { emptyMap() },
+ trackScope = IOScope(Dispatchers.Unconfined),
+ )
+
+ init {
+ ids.forEachIndexed { index, id ->
+ store.cacheProduct(
+ id,
+ mockk(relaxed = true) {
+ every { fullIdentifier } returns id
+ every { attributes } returns mapOf("identifier" to id, "price" to prices[index])
+ },
+ )
+ }
+ }
+
+ suspend fun load(): Paywall {
+ val result =
+ manager.getPaywall(
+ PaywallRequest(
+ null,
+ ResponseIdentifiers(config.paywalls.single().identifier),
+ PaywallRequest.Overrides(null, false),
+ false,
+ "register",
+ 0,
+ ),
+ )
+ check(result is Either.Success) { "Product request failed: $result" }
+ assertNull("Prices must load without billing errors", result.value.productsLoadingInfo.failAt)
+ return result.value
+ }
+
+ suspend fun templates(paywall: Paywall): JsonArray {
+ val variablesFactory = mockk()
+ coEvery { variablesFactory.makeJsonVariables(any(), any(), any()) } coAnswers {
+ Variables(firstArg(), emptyMap(), emptyMap(), emptyMap()).templated()
+ }
+ return json.parseToJsonElement(TemplateLogic.getBase64EncodedTemplates(json, paywall, null, variablesFactory) { it }).jsonArray
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
index 281f42933..00d40888a 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
@@ -380,6 +380,10 @@ class PaywallMessageHandlerTest {
private inner class FakePaywallWebUI(
override val messageHandler: PaywallMessageHandler,
) : PaywallWebUI {
+ override fun onPause() = Unit
+
+ override fun onResume() = Unit
+
override var delegate: PaywallUIDelegate? = null
val evaluateCalls = mutableListOf()
private val view = View(context)
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
index d6b265535..125f239c5 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
@@ -517,6 +517,10 @@ class PaywallViewTest {
private inner class FakePaywallWebUI(
override val messageHandler: PaywallMessageHandler,
) : PaywallWebUI {
+ override fun onPause() = Unit
+
+ override fun onResume() = Unit
+
override var delegate: PaywallUIDelegate? = null
var lastScrollBy: Pair? = null
var lastScrollTo: Pair? = null
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt
new file mode 100644
index 000000000..1f39c2c69
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt
@@ -0,0 +1,211 @@
+package com.superwall.sdk.paywall.view
+
+import android.Manifest
+import android.app.Application
+import android.content.pm.PackageManager
+import androidx.test.core.app.ApplicationProvider
+import com.superwall.sdk.dependencies.DeviceHelperFactory
+import com.superwall.sdk.models.paywall.LocalNotification
+import com.superwall.sdk.models.paywall.LocalNotificationType
+import com.superwall.sdk.store.transactions.notifications.NotificationScheduler
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowSystemClock
+import java.time.Duration
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], manifest = Config.NONE)
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+class TrialNotificationPermissionTest {
+ private val notifications =
+ listOf(LocalNotification("trial", LocalNotificationType.TrialStarted, "Trial ending", body = "Reminder", delay = 86_400_000L))
+ private val factory = mockk()
+
+ @Before
+ fun setup() {
+ mockkObject(NotificationScheduler.Companion)
+ every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } just Runs
+ every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } just Runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(NotificationScheduler.Companion)
+ }
+
+ @Test
+ fun `permission wait is subtracted from absolute web reminder delay`() =
+ runTest {
+ val activity = deniedActivity()
+ val job = launchWait(activity, applySandboxScaling = false)
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(10))
+ activity.deliverPermission(granted = true)
+ job.join()
+ verify(exactly = 1) {
+ NotificationScheduler.scheduleNotifications(
+ listOf(notifications.single().copy(delay = 86_390_000L)),
+ factory,
+ activity,
+ false,
+ false,
+ )
+ }
+ }
+
+ @Test
+ fun `reminder that expires during permission wait is skipped`() =
+ runTest {
+ val activity = deniedActivity()
+ val job =
+ launch {
+ activity.attemptToScheduleNotifications(
+ listOf(notifications.single().copy(delay = 5_000L)),
+ factory,
+ false,
+ false,
+ )
+ }
+ runCurrent()
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(10))
+ activity.deliverPermission(granted = true)
+ job.join()
+ verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `cancelled permission wait still schedules a late grant once`() =
+ runTest {
+ val activity = deniedActivity()
+ val job = launchWait(activity)
+ job.cancelAndJoin()
+ repeat(2) { activity.deliverPermission(granted = true) }
+ verify(exactly = 1) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `destroyed activity releases the permission waiter`() =
+ runTest {
+ val controller = deniedActivityController()
+ val activity = controller.get()
+ val job = launchWait(activity)
+ controller.destroy()
+ runCurrent()
+ assertTrue(job.isCompleted)
+ activity.deliverPermission(granted = true)
+ verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `web reminders bypass native sandbox delay scaling`() =
+ runTest {
+ val activity = grantedActivity()
+ activity.attemptToScheduleNotifications(notifications, factory, cancelExisting = false, applySandboxScaling = false)
+ verify(exactly = 1) { NotificationScheduler.scheduleNotifications(notifications, factory, activity, false, false) }
+ }
+
+ @Test
+ fun `granted notification permission schedules the reminders`() =
+ runTest {
+ val activity = grantedActivity()
+ activity.attemptToScheduleNotifications(notifications, factory)
+ verify(exactly = 1) { NotificationScheduler.scheduleNotifications(notifications, factory, activity, false) }
+ }
+
+ @Test
+ fun `denied permission completes the attempt without scheduling`() =
+ runTest {
+ val activity = deniedActivity()
+ val job = launchWait(activity)
+ assertFalse(job.isCompleted)
+ activity.deliverPermission(granted = false)
+ runCurrent()
+ assertTrue(job.isCompleted)
+ verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `scheduling failure after permission grant reaches the waiting caller`() =
+ runTest {
+ val activity = deniedActivity()
+ val failure = IllegalStateException("WorkManager unavailable")
+ every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } throws failure
+ var received: Exception? = null
+ val job =
+ launch {
+ try {
+ activity.attemptToScheduleNotifications(notifications, factory)
+ } catch (e: Exception) {
+ received = e
+ }
+ }
+ runCurrent()
+ activity.deliverPermission(granted = true)
+ runCurrent()
+ assertTrue(job.isCompleted)
+ assertTrue(received is IllegalStateException)
+ assertEquals(failure.message, received?.message)
+ }
+
+ private fun app() = ApplicationProvider.getApplicationContext()
+
+ private fun grantedActivity(): SuperwallPaywallActivity {
+ shadowOf(app()).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
+ return Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get()
+ }
+
+ private fun deniedActivity(): SuperwallPaywallActivity {
+ shadowOf(app()).denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
+ return Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get()
+ }
+
+ private fun deniedActivityController(): ActivityController {
+ shadowOf(app()).denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
+ val controller = Robolectric.buildActivity(SuperwallPaywallActivity::class.java)
+ controller.get().setTheme(androidx.appcompat.R.style.Theme_AppCompat)
+ return controller.create()
+ }
+
+ private fun TestScope.launchWait(
+ activity: SuperwallPaywallActivity,
+ applySandboxScaling: Boolean = true,
+ ): Job {
+ val job =
+ launch {
+ activity.attemptToScheduleNotifications(notifications, factory, false, applySandboxScaling)
+ }
+ runCurrent()
+ return job
+ }
+
+ private fun SuperwallPaywallActivity.deliverPermission(granted: Boolean) {
+ val request = shadowOf(this).lastRequestedPermission
+ onRequestPermissionsResult(
+ request.requestCode,
+ request.requestedPermissions,
+ intArrayOf(if (granted) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED),
+ )
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt
new file mode 100644
index 000000000..98043f7c0
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt
@@ -0,0 +1,82 @@
+package com.superwall.sdk.paywall.view.webview
+
+import android.app.Activity
+import android.os.Looper
+import android.view.View
+import com.superwall.sdk.config.options.PaywallOptions
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.util.ReflectionHelpers
+
+@RunWith(RobolectricTestRunner::class)
+class SWWebViewMediaLifecycleTest {
+ @Test
+ fun `media follows host pause visibility and cached reattachment`() {
+ val controller = Robolectric.buildActivity(Activity::class.java).setup()
+ val activity = controller.get()
+ val webView = SWWebView(activity, mockk(relaxed = true), options = { PaywallOptions() })
+ try {
+ // A preload must remain paused even if its host resumes.
+ webView.onResume()
+ assertMediaAllowed(webView, false)
+
+ activity.setContentView(webView)
+ setWindowVisibility(webView, View.VISIBLE)
+ assertMediaAllowed(webView, true)
+ webView.onPause()
+ assertMediaAllowed(webView, false)
+ webView.onResume()
+ assertMediaAllowed(webView, true)
+
+ webView.visibility = View.GONE
+ assertMediaAllowed(webView, false)
+ webView.visibility = View.VISIBLE
+ assertMediaAllowed(webView, true)
+
+ setWindowVisibility(webView, View.GONE)
+ assertMediaAllowed(webView, false)
+ setWindowVisibility(webView, View.VISIBLE)
+ assertMediaAllowed(webView, true)
+
+ // Detaching dispatches window visibility before the window state changes, so a
+ // resumed view must still end paused.
+ activity.setContentView(View(activity))
+ assertMediaAllowed(webView, false)
+ activity.setContentView(webView)
+ assertMediaAllowed(webView, true)
+
+ webView.onPause()
+ activity.setContentView(View(activity))
+ assertMediaAllowed(webView, false)
+ // Reusing a dismissed paywall in another host must clear the old pause state.
+ activity.setContentView(webView)
+ assertMediaAllowed(webView, true)
+ } finally {
+ controller.pause().stop().destroy()
+ webView.destroy()
+ }
+ }
+
+ private fun setWindowVisibility(webView: SWWebView, visibility: Int) {
+ shadowOf(Looper.getMainLooper()).idle()
+ // Robolectric attaches the view but leaves the window GONE. Emulate WindowManager's
+ // visibility update before dispatching the same callback Android uses.
+ val attachInfo = ReflectionHelpers.getField(webView, "mAttachInfo")
+ ReflectionHelpers.setField(attachInfo, "mWindowVisibility", visibility)
+ webView.dispatchWindowVisibilityChanged(visibility)
+ }
+
+ private fun assertMediaAllowed(webView: SWWebView, allowed: Boolean) {
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(
+ "attached=${webView.isAttachedToWindow}, shown=${webView.isShown}, window=${webView.windowVisibility}",
+ MediaPlaybackScript.build(allowed),
+ shadowOf(webView).lastEvaluatedJavascript,
+ )
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt
new file mode 100644
index 000000000..179f00607
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt
@@ -0,0 +1,548 @@
+package com.superwall.sdk.paywall.view.webview.messaging
+
+import android.app.Activity
+import com.superwall.sdk.Given
+import com.superwall.sdk.Then
+import com.superwall.sdk.When
+import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
+import com.superwall.sdk.config.options.SuperwallOptions
+import com.superwall.sdk.dependencies.OptionsFactory
+import com.superwall.sdk.dependencies.VariablesFactory
+import com.superwall.sdk.misc.IOScope
+import com.superwall.sdk.misc.MainScope
+import com.superwall.sdk.models.config.ComputedPropertyRequest
+import com.superwall.sdk.models.events.EventData
+import com.superwall.sdk.models.paywall.Paywall
+import com.superwall.sdk.models.product.ProductVariable
+import com.superwall.sdk.paywall.presentation.CustomCallbackRegistry
+import com.superwall.sdk.paywall.view.PaywallViewState
+import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState
+import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables
+import com.superwall.sdk.paywall.view.webview.templating.models.Variables
+import com.superwall.sdk.permissions.PermissionStatus
+import com.superwall.sdk.permissions.PermissionType
+import com.superwall.sdk.permissions.UserPermissions
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+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 java.util.Date
+
+/**
+ * The paywall runtime treats a `template_variables` message that lands after
+ * `paywall_open` as a fresh load, then discards later `page_view`s. These tests
+ * pin that open waits for an in-flight template send, without serializing
+ * unrelated events like `transaction_start`.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class PaywallMessageOrderingTest {
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private class RecordingDelegate(
+ initial: PaywallViewState,
+ ) : PaywallMessageHandlerDelegate {
+ private var _state: PaywallViewState = initial
+ override val state: PaywallViewState
+ get() = _state
+
+ val evaluations = mutableListOf()
+
+ override fun updateState(update: PaywallViewState.Updates) {
+ _state = update.transform(_state)
+ }
+
+ override fun eventDidOccur(paywallWebEvent: PaywallWebEvent) {}
+
+ override fun openDeepLink(url: String) {}
+
+ override fun presentBrowserInApp(url: String) {}
+
+ override fun presentBrowserExternal(url: String) {}
+
+ override fun evaluate(
+ code: String,
+ resultCallback: ((String?) -> Unit)?,
+ ) {
+ evaluations.add(code)
+ resultCallback?.invoke(null)
+ }
+
+ override fun presentPaymentSheet(url: String) {}
+ }
+
+ private class SlowVariablesFactory(
+ private val fail: Boolean = false,
+ private val hang: Boolean = false,
+ private val delaysMs: List = listOf(TEMPLATE_BUILD_MS),
+ ) : VariablesFactory {
+ private val remaining = delaysMs.toMutableList()
+
+ override suspend fun makeJsonVariables(
+ products: List?,
+ computedPropertyRequests: List,
+ event: EventData?,
+ ): JsonVariables {
+ if (hang) awaitCancellation()
+ val wait = if (remaining.isEmpty()) delaysMs.last() else remaining.removeAt(0)
+ delay(wait)
+ if (fail) throw IllegalStateException("could not build the templates")
+ return JsonVariables("template_variables", Variables(emptyMap(), emptyMap(), emptyMap()))
+ }
+ }
+
+ private class FakeUserPermissions : UserPermissions {
+ override fun hasPermission(permission: PermissionType): PermissionStatus = PermissionStatus.GRANTED
+
+ override suspend fun requestPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus = PermissionStatus.GRANTED
+ }
+
+ private fun createHandler(
+ failTemplateBuild: Boolean = false,
+ hangTemplateBuild: Boolean = false,
+ buildDelayMs: Long = TEMPLATE_BUILD_MS,
+ delaysMs: List? = null,
+ ): PaywallMessageHandler =
+ PaywallMessageHandler(
+ factory =
+ SlowVariablesFactory(
+ fail = failTemplateBuild,
+ hang = hangTemplateBuild,
+ delaysMs = delaysMs ?: listOf(buildDelayMs),
+ ),
+ options =
+ object : OptionsFactory {
+ override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
+ },
+ track = { _: TrackableSuperwallEvent -> },
+ setAttributes = { },
+ getView = { null },
+ mainScope = MainScope(testDispatcher),
+ ioScope = IOScope(testDispatcher),
+ encodeToB64 = { it },
+ userPermissions = FakeUserPermissions(),
+ getActivity = { null },
+ customCallbackRegistry = CustomCallbackRegistry(),
+ )
+
+ private fun List.indexOfMessage(needle: String): Int = indexOfFirst { it.contains(needle) }
+
+ private fun assertTemplatesPrecedeOpen(evaluations: List) {
+ val templates = evaluations.indexOfMessage(TEMPLATE_VARIABLES)
+ val open = evaluations.indexOfMessage(PAYWALL_OPEN)
+ assertTrue("template_variables was never sent to the webview", templates >= 0)
+ assertTrue("paywall_open was never sent to the webview", open >= 0)
+ assertTrue(
+ "paywall_open (index $open) overtook template_variables (index $templates)",
+ templates < open,
+ )
+ }
+
+ @Test
+ fun templateVariablesReachWebviewBeforePaywallOpenOnAPreloadedPaywall() =
+ runTest {
+ Given("a paywall whose webview has already loaded") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ When("presentation sends the templates and the open follows immediately") {
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ Then("the templates reach the webview first") {
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun templateVariablesReachWebviewBeforePaywallOpenWhenPaywallIsNotPreloaded() =
+ runTest {
+ Given("a paywall whose webview has just reported it is ready") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ When("the open lands while the templates are still being built") {
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ Then("the templates reach the webview first") {
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun deferredPaywallOpenIsSentAfterTheTemplatesOnceTheWebviewLoads() =
+ runTest {
+ Given("an open deferred while the webview is being recreated") {
+ val state =
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = true,
+ lastOpen = Date(1),
+ )
+ val delegate = RecordingDelegate(state)
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ When("the replacement webview finishes loading") {
+ handler.resetForWebViewReload()
+ handler.flushPendingMessages()
+ advanceUntilIdle()
+ assertTrue(
+ "the deferred open was sent before the webview loaded",
+ delegate.evaluations.indexOfMessage(PAYWALL_OPEN) < 0,
+ )
+
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ advanceUntilIdle()
+
+ Then("the open is delivered after the templates") {
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun flushWhileTheWebviewIsUnloadedLeavesTheOpenQueued() =
+ runTest {
+ Given("a crash-recovery open queued against an unloaded webview") {
+ val state =
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = true,
+ lastOpen = Date(1),
+ )
+ val delegate = RecordingDelegate(state)
+ val handler = createHandler()
+ handler.messageHandler = delegate
+ handler.resetForWebViewReload()
+
+ When("flush runs before OnReady") {
+ handler.flushPendingMessages()
+ advanceUntilIdle()
+
+ Then("the open stays queued and the flush returns") {
+ assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN))
+ assertTrue(delegate.evaluations.isEmpty())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun overlappingTemplateSendsBothFinishBeforePaywallOpen() =
+ runTest {
+ Given("initialization is still building templates") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ val handler =
+ createHandler(
+ // First build (OnReady) is slower than the second so they
+ // finish out of arrival order if open only waits on the latest.
+ delaysMs = listOf(1_000L, 50L),
+ )
+ handler.messageHandler = delegate
+
+ When("a second template send starts before the first finishes, then open arrives") {
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ runCurrent()
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ Then("both template payloads reach the webview before paywall_open") {
+ val templateCount = delegate.evaluations.count { it.contains(TEMPLATE_VARIABLES) }
+ assertEquals(2, templateCount)
+ val lastTemplate =
+ delegate.evaluations.indexOfLast { it.contains(TEMPLATE_VARIABLES) }
+ val open = delegate.evaluations.indexOfMessage(PAYWALL_OPEN)
+ assertTrue(lastTemplate >= 0)
+ assertTrue(open >= 0)
+ assertTrue(
+ "paywall_open (index $open) overtook a template send (last index $lastTemplate)",
+ lastTemplate < open,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun aFailedTemplateBuildDoesNotDropPaywallOpen() =
+ runTest {
+ Given("a paywall whose template build throws") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler(failTemplateBuild = true)
+ handler.messageHandler = delegate
+
+ When("an open is waiting on the failing template send") {
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ Then("the open is still delivered") {
+ assertTrue(
+ "paywall_open was lost behind a failed template send",
+ delegate.evaluations.indexOfMessage(PAYWALL_OPEN) >= 0,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun aHungTemplateBuildDoesNotSilencePaywallOpen() =
+ runTest {
+ Given("a template build that never completes") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler(hangTemplateBuild = true)
+ handler.messageHandler = delegate
+
+ When("open waits on that build past the bound") {
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceTimeBy(TEMPLATE_OPEN_WAIT_MS + 1)
+ runCurrent()
+
+ Then("the timeout is the only path that delivers open, and templates stay in flight") {
+ assertTrue(
+ "paywall_open was lost behind a hung template send",
+ delegate.evaluations.indexOfMessage(PAYWALL_OPEN) >= 0,
+ )
+ assertEquals(-1, delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES))
+ }
+ }
+ }
+ }
+
+ @Test
+ fun aSlowTemplateBuildStillReachesReadyAfterTheWaitBound() =
+ runTest {
+ Given("initialization that finishes after the open wait bound") {
+ val state = PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")
+ val delegate = RecordingDelegate(state)
+ val handler = createHandler(buildDelayMs = TEMPLATE_OPEN_WAIT_MS + 2_000L)
+ handler.messageHandler = delegate
+
+ When("open is sent while templates are still building") {
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceTimeBy(TEMPLATE_OPEN_WAIT_MS + 1)
+ runCurrent()
+
+ Then("open is delivered at the bound without cancelling the template send") {
+ assertTrue(delegate.evaluations.indexOfMessage(PAYWALL_OPEN) >= 0)
+ assertEquals(-1, delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES))
+ assertEquals(PaywallLoadingState.Unknown, delegate.state.loadingState)
+ }
+
+ advanceUntilIdle()
+
+ Then("templates still post and the paywall reaches Ready") {
+ assertTrue(delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES) >= 0)
+ assertEquals(PaywallLoadingState.Ready, delegate.state.loadingState)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun transactionStartIsNotHeldBehindSlowTemplates() =
+ runTest {
+ val delegate =
+ RecordingDelegate(
+ PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US"),
+ )
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ handler.handle(PaywallMessage.TransactionStart)
+ runCurrent()
+
+ assertTrue(
+ "transaction_start should not wait on template construction",
+ delegate.evaluations.indexOfMessage("transaction_start") >= 0,
+ )
+ assertEquals(-1, delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES))
+
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ }
+
+ @Test
+ fun slowInitializationFinishesBeforeOpeningANewPaywall() =
+ runTest {
+ for (openBeforeReady in listOf(false, true)) {
+ val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US"))
+ val handler = createHandler(buildDelayMs = 3_000L)
+ handler.messageHandler = delegate
+
+ if (openBeforeReady) handler.handle(PaywallMessage.PaywallOpen)
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ if (!openBeforeReady) handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ assertEquals(PaywallLoadingState.Ready, delegate.state.loadingState)
+ }
+ }
+
+ @Test
+ fun recoveryDoesNotOpenCachedOrBackgroundedPaywalls() =
+ runTest {
+ for (presented in listOf(false, true)) {
+ val delegate =
+ RecordingDelegate(
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = presented,
+ closedForBackground = presented,
+ ),
+ )
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ handler.resetForWebViewReload()
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ advanceUntilIdle()
+
+ assertTrue(delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES) >= 0)
+ assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN))
+ }
+ }
+
+ @Test
+ fun recoveryDoesNotReopenAPaywallDismissedOrBackgroundedWhileLoading() =
+ runTest {
+ for (update in listOf(PaywallViewState.Updates.CleanupAfterDestroy, PaywallViewState.Updates.SetClosedForBackground(true))) {
+ val delegate =
+ RecordingDelegate(
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = true,
+ lastOpen = Date(1),
+ ),
+ )
+ val handler = createHandler()
+ handler.messageHandler = delegate
+
+ handler.resetForWebViewReload()
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ handler.flushPendingMessages()
+ runCurrent()
+ delegate.updateState(update)
+ advanceUntilIdle()
+
+ assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN))
+ }
+ }
+
+ @Test
+ fun recoveryOpenDoesNotCarryOverToANewPresentation() =
+ runTest {
+ val delegate =
+ RecordingDelegate(
+ PaywallViewState(
+ paywall = Paywall.stub().copy(paywalljsVersion = null),
+ locale = "en-US",
+ isPresented = true,
+ lastOpen = Date(1),
+ ),
+ )
+ val handler = createHandler()
+ handler.messageHandler = delegate
+ handler.resetForWebViewReload()
+ delegate.updateState(PaywallViewState.Updates.CleanupAfterDestroy)
+ delegate.updateState(PaywallViewState.Updates.SetPresentedAndFinished)
+ delegate.updateState(PaywallViewState.Updates.SetLastOpen)
+
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ handler.handle(PaywallMessage.PaywallOpen)
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ assertEquals(1, delegate.evaluations.count { it.contains(PAYWALL_OPEN) })
+ }
+
+ @Test
+ fun replacingWebViewCancelsOldTemplates() =
+ runTest {
+ val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US"))
+ delegate.updateState(PaywallViewState.Updates.SetPaywallJsVersion(PAYWALL_JS_VERSION))
+ val handler = createHandler()
+ handler.messageHandler = delegate
+ handler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
+ runCurrent()
+
+ handler.resetForWebViewReload()
+ assertNull(delegate.state.paywall.paywalljsVersion)
+ handler.handle(PaywallMessage.PaywallOpen)
+ handler.flushPendingMessages()
+ advanceUntilIdle()
+ assertEquals(-1, delegate.evaluations.indexOfMessage(TEMPLATE_VARIABLES))
+ assertEquals(-1, delegate.evaluations.indexOfMessage(PAYWALL_OPEN))
+
+ handler.handle(PaywallMessage.OnReady(paywallJsVersion = PAYWALL_JS_VERSION))
+ advanceUntilIdle()
+
+ assertTemplatesPrecedeOpen(delegate.evaluations)
+ assertEquals(1, delegate.evaluations.count { it.contains(TEMPLATE_VARIABLES) })
+ }
+
+ private companion object {
+ const val TEMPLATE_BUILD_MS = 500L
+ const val PAYWALL_JS_VERSION = "3.0.0"
+ const val TEMPLATE_VARIABLES = "template_variables"
+ const val PAYWALL_OPEN = "paywall_open"
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt
new file mode 100644
index 000000000..76ab64cbf
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt
@@ -0,0 +1,166 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import com.superwall.sdk.models.internal.WebRedemptionResponse
+import com.superwall.sdk.network.JsonFactory
+import com.superwall.sdk.storage.LatestRedemptionResponse
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import org.junit.Assert.*
+import org.junit.Test
+import java.math.BigDecimal
+import java.util.Date
+
+class RedemptionStoreProductTest {
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+ private val response = trialRedemptionFixture()
+ private val info = (response.codes.single() as RedemptionResult.Success).redemptionInfo
+ private val product = info.paywallInfo!!.product!!
+
+ @Test
+ fun `original Kotlin default constructor and copy bytecode signatures remain callable`() {
+ val type = RedemptionResult.PaywallInfo::class.java
+ val marker = Class.forName("kotlin.jvm.internal.DefaultConstructorMarker")
+ val constructor =
+ type.getConstructor(
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ Int::class.javaPrimitiveType,
+ marker,
+ )
+ val created = constructor.newInstance("paywall", "placement", emptyMap(), "variant", "experiment", null, 32, null)
+ assertNull(created.productIdentifier)
+ val oldCopy =
+ type.getMethod(
+ "copy",
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ )
+ val source = info.paywallInfo!!
+ val fullCopy =
+ oldCopy.invoke(
+ source,
+ source.identifier,
+ "changed",
+ source.placementParams,
+ source.variantId,
+ source.experimentId,
+ source.productIdentifier,
+ )
+ assertEquals(source.copy(placementName = "changed"), fullCopy)
+ val defaultCopy =
+ type.getMethod(
+ "copy\$default",
+ type,
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ Int::class.javaPrimitiveType,
+ Any::class.java,
+ )
+ val copied = defaultCopy.invoke(null, source, null, "changed", null, null, null, null, 61, null) as RedemptionResult.PaywallInfo
+ assertEquals("changed", copied.placementName)
+ assertEquals(product, copied.product)
+ assertEquals(source.identifier, copied.component1())
+ assertEquals(source.productIdentifier, copied.component6())
+ }
+
+ @Test
+ fun `existing six argument Java constructor remains available`() {
+ val constructor =
+ RedemptionResult.PaywallInfo::class.java.getConstructor(
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ )
+ val legacy = constructor.newInstance("paywall", "placement", emptyMap(), "variant", "experiment", "product")
+ assertEquals("product", legacy.productIdentifier)
+ assertNull(legacy.product)
+ }
+
+ @Test
+ fun `all checkout product variables survive decoding and cache round trip`() {
+ val fixture = requireNotNull(javaClass.getResource("/web-redemption-trial.json")).readText()
+ val expected =
+ json
+ .parseToJsonElement(fixture)
+ .jsonObject["codes"]!!
+ .jsonArray
+ .single()
+ .jsonObject["redemptionInfo"]!!
+ .jsonObject["paywallInfo"]!!
+ .jsonObject["product"]
+ assertEquals(expected, json.encodeToJsonElement(PaywallProduct.serializer(), product))
+ val cacheJson = JsonFactory.JSON
+ val cached = cacheJson.encodeToString(LatestRedemptionResponse.serializer, response)
+ val restored = cacheJson.decodeFromString(LatestRedemptionResponse.serializer, cached)
+ assertEquals(response.codes, restored.codes)
+ assertEquals(response.customerInfo, restored.customerInfo)
+ }
+
+ @Test
+ fun `legacy and null product responses still decode`() {
+ val encoded = json.encodeToJsonElement(RedemptionResult.PaywallInfo.serializer(), info.paywallInfo!!).jsonObject
+ for (legacy in listOf(JsonObject(encoded - "product"), JsonObject(encoded + ("product" to JsonNull)))) {
+ val decoded = json.decodeFromJsonElement(RedemptionResult.PaywallInfo.serializer(), legacy)
+ assertNull(decoded.product)
+ assertEquals("test_product", decoded.productIdentifier)
+ }
+ }
+
+ @Test
+ fun `product is retained without the legacy identifier`() {
+ val encoded = json.encodeToJsonElement(RedemptionResult.PaywallInfo.serializer(), info.paywallInfo!!).jsonObject
+ val decoded = json.decodeFromJsonElement(RedemptionResult.PaywallInfo.serializer(), JsonObject(encoded - "productIdentifier"))
+ assertNull(decoded.productIdentifier)
+ assertEquals(product, decoded.product)
+ }
+
+ @Test
+ fun `adapter preserves prices periods and trial end rather than recalculating them`() {
+ val adapted = RedemptionStoreProduct(product)
+ assertEquals(BigDecimal("9.99"), adapted.price)
+ assertEquals("$0.00", adapted.localizedTrialPeriodPrice)
+ assertEquals("7-day free trial", adapted.trialPeriodText)
+ assertEquals("mo", adapted.attributes["periodAlt"])
+ assertEquals("month", adapted.attributes["localizedPeriod"])
+ assertEquals("2026-09-14T12:30:00.000Z", adapted.attributes["trialPeriodEndDate"])
+ assertEquals(Date(1789389000000L), adapted.trialPeriodEndDate)
+ assertEquals("$0.00", adapted.attributes["trialPeriodWeeklyPrice"])
+ }
+
+ @Test
+ fun `date only and invalid trial dates do not affect original callback text`() {
+ val dateOnly = RedemptionStoreProduct(product.copy(trialPeriodEndDate = "2026-09-14"))
+ assertEquals(Date(1789344000000L), dateOnly.trialPeriodEndDate)
+ val offset = RedemptionStoreProduct(product.copy(trialPeriodEndDate = "2026-09-14T14:30:00+02:00"))
+ assertEquals(Date(1789389000000L), offset.trialPeriodEndDate)
+ for (value in listOf("", "not a date", "+999999999-09-14T12:30:00Z", "+999999999-09-14")) {
+ val adapted = RedemptionStoreProduct(product.copy(trialPeriodEndDate = value))
+ assertNull(adapted.trialPeriodEndDate)
+ assertEquals(value, adapted.trialPeriodEndDateString)
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt
new file mode 100644
index 000000000..b914d1fca
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt
@@ -0,0 +1,373 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.analytics.internal.trackable.Trackable
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.IOScope
+import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.models.internal.DeviceVendorId
+import com.superwall.sdk.models.internal.ErrorInfo
+import com.superwall.sdk.models.internal.RedemptionResult
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import com.superwall.sdk.models.internal.UserId
+import com.superwall.sdk.models.internal.VendorId
+import com.superwall.sdk.models.internal.WebRedemptionResponse
+import com.superwall.sdk.models.paywall.LocalNotification
+import com.superwall.sdk.models.paywall.LocalNotificationType
+import com.superwall.sdk.models.triggers.Experiment
+import com.superwall.sdk.network.Network
+import com.superwall.sdk.paywall.presentation.PaywallInfo
+import com.superwall.sdk.storage.LatestRedemptionResponse
+import com.superwall.sdk.storage.Storage
+import com.superwall.sdk.storage.TrackedWebTrialCodes
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import org.junit.Assert.*
+import org.junit.Test
+
+internal fun trialRedemptionFixture(): WebRedemptionResponse =
+ Json { ignoreUnknownKeys = true }.decodeFromString(
+ requireNotNull(WebRedemptionTrialTest::class.java.getResource("/web-redemption-trial.json")).readText(),
+ )
+
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+class WebRedemptionTrialTest {
+ private var response = trialRedemptionFixture()
+ private val factory = mockk(relaxed = true)
+ private val network = mockk()
+ private val storage = mockk(relaxed = true)
+ private val events = mutableListOf()
+ private val order = mutableListOf()
+ private val reminder =
+ LocalNotification("reminder", LocalNotificationType.TrialStarted, "Trial ending", body = "Reminder", delay = 86_400_000L)
+ private var paywallInfo =
+ PaywallInfo.empty().copy(
+ identifier = "active_paywall",
+ experiment =
+ Experiment(
+ "active_experiment",
+ "group",
+ Experiment.Variant("active_variant", Experiment.Variant.VariantType.TREATMENT, "active_paywall"),
+ ),
+ isFreeTrialAvailable = true,
+ localNotifications = listOf(reminder),
+ )
+ private var trackedCodes = emptySet()
+ private var visible = true
+ private val result get() = response.codes.single() as RedemptionResult.Success
+
+ init {
+ every { factory.isWebToAppEnabled() } returns false
+ every { factory.getUserId() } returns UserId("appUserId")
+ every { factory.getDeviceId() } returns DeviceVendorId(VendorId("test-device"))
+ every { factory.getAliasId() } returns null
+ every { factory.getActiveDeviceEntitlements() } returns emptySet()
+ every { factory.currentPaywallEntitlements() } answers { response.customerInfo!!.entitlements.toSet() }
+ every { factory.maxAge() } returns 60_000L
+ every { factory.currentTimeMillis() } returns 1788784200000L // Checkout: September 7, 12:30 UTC
+ every { storage.read(TrackedWebTrialCodes) } answers { trackedCodes }
+ every { storage.write(TrackedWebTrialCodes, any()) } answers { trackedCodes = secondArg() }
+ every { factory.getIntegrationProps() } returns emptyMap()
+ every { factory.getExternalAccountId() } returns ""
+ coEvery { factory.receipts() } returns emptyList()
+ coEvery { factory.isPaywallVisible() } answers { visible }
+ every { factory.isPaymentSheetOpen() } returns false
+ every { factory.getPaywallInfo() } answers { paywallInfo }
+ every { factory.internallySetSubscriptionStatus(any()) } answers { order += "access" }
+ coEvery { factory.track(any()) } coAnswers {
+ events += firstArg()
+ if (firstArg() is InternalSuperwallEvent.FreeTrialStart) order += "trial"
+ }
+ coEvery { factory.scheduleTrialNotifications(any()) } coAnswers { order += "schedule" }
+ coEvery { factory.triggerRestoreInPaywall() } coAnswers { order += "restore" }
+ every { factory.closePaywallIfExists() } answers { order += "close" }
+ every { factory.didRedeemLink(any()) } answers { order += "callback" }
+ every { storage.read(LatestRedemptionResponse) } returns null
+ coEvery { network.redeemToken(any(), any(), any(), any(), any(), any(), any()) } coAnswers { Either.Success(response) }
+ coEvery { network.webEntitlementsByUserId(any(), any()) } coAnswers { awaitCancellation() }
+ }
+
+ private suspend fun TestScope.redeem(type: WebPaywallRedeemer.RedeemType = WebPaywallRedeemer.RedeemType.Code("TESTCODE")) {
+ val scope = IOScope(StandardTestDispatcher(testScheduler))
+ try {
+ WebPaywallRedeemer(mockk(), scope, mockk(), network, storage, mockk(relaxed = true), factory).redeem(type)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ private fun changeProduct(transform: (PaywallProduct) -> PaywallProduct?) {
+ val info = result.redemptionInfo
+ response =
+ WebRedemptionResponse(
+ customerInfo = response.customerInfo,
+ codes =
+ listOf(
+ result.copy(
+ redemptionInfo =
+ info.copy(
+ paywallInfo = info.paywallInfo!!.copy(product = transform(info.paywallInfo.product!!)),
+ ),
+ ),
+ ),
+ )
+ }
+
+ private fun assertNoTrialSideEffects() {
+ assertTrue(events.none { it is InternalSuperwallEvent.FreeTrialStart })
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ }
+
+ @Test
+ fun `missing permission result cannot strand successful redemption`() =
+ runTest {
+ coEvery { factory.scheduleTrialNotifications(any()) } coAnswers { awaitCancellation() }
+ val job = launch { redeem() }
+ runCurrent()
+ advanceTimeBy(WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS)
+ runCurrent()
+ assertTrue(job.isCompleted)
+ verify(exactly = 1) { factory.didRedeemLink(result) }
+ verify(exactly = 1) { factory.closePaywallIfExists() }
+ }
+
+ @Test
+ fun `repeated success after recreating redeemer only tracks one trial`() =
+ runTest {
+ redeem()
+ // The new redeemer reads the persisted marker, as it would after an app restart.
+ redeem()
+ assertEquals(setOf("TESTCODE"), trackedCodes)
+ assertEquals(1, events.filterIsInstance().size)
+ verify(exactly = 2) { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `overlapping same-code redemptions emit freeTrial_start once`() =
+ runTest {
+ val trackingStarted = CompletableDeferred()
+ val releaseTracking = CompletableDeferred()
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } coAnswers {
+ if (!trackingStarted.isCompleted) trackingStarted.complete(Unit)
+ releaseTracking.await()
+ events += firstArg()
+ }
+ val scope = IOScope(StandardTestDispatcher(testScheduler))
+ val redeemer = WebPaywallRedeemer(mockk(), scope, mockk(), network, storage, mockk(relaxed = true), factory)
+ try {
+ val first = launch { redeemer.redeem(WebPaywallRedeemer.RedeemType.Code("TESTCODE")) }
+ val second = launch { redeemer.redeem(WebPaywallRedeemer.RedeemType.Code("TESTCODE")) }
+ trackingStarted.await()
+ runCurrent()
+ releaseTracking.complete(Unit)
+ first.join()
+ second.join()
+ } finally {
+ scope.cancel()
+ }
+ assertEquals(1, events.filterIsInstance().size)
+ assertEquals(setOf("TESTCODE"), trackedCodes)
+ }
+
+ @Test
+ fun `failed redemption can subsequently start a trial`() =
+ runTest {
+ val success = response
+ response =
+ WebRedemptionResponse(
+ codes = listOf(RedemptionResult.Error("TESTCODE", ErrorInfo("retry"))),
+ customerInfo = success.customerInfo,
+ )
+ every { storage.read(LatestRedemptionResponse) } returns response
+ redeem()
+ assertTrue(trackedCodes.isEmpty())
+ response = success
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ }
+
+ @Test
+ fun `tracking failure does not mark the trial as emitted`() =
+ runTest {
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } throws IllegalStateException("retry")
+ redeem()
+ assertTrue(trackedCodes.isEmpty())
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } coAnswers { events += firstArg() }
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ }
+
+ @Test
+ fun `late redemption does not schedule an already missed reminder`() =
+ runTest {
+ every { factory.currentTimeMillis() } returns 1788957000000L // Two days after checkout; reminder was due after one.
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ verify { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `display only trial date still delivers callback and analytics`() =
+ runTest {
+ changeProduct { it.copy(trialPeriodEndDate = "September 14, 2026") }
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ verify { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `eligible redemption exposes product and tracks original trial data before either dismissal`() =
+ runTest {
+ redeem()
+ assertEquals(listOf("access", "trial", "schedule", "restore", "close", "callback"), order)
+ verify(exactly = 1) { factory.didRedeemLink(result) }
+ verify { factory.internallySetSubscriptionStatus(SubscriptionStatus.Active(response.customerInfo!!.entitlements.toSet())) }
+ coVerify(exactly = 1) {
+ factory.scheduleTrialNotifications(listOf(reminder.copy(id = "active_paywall_TRIAL_STARTED")))
+ }
+ val event = events.filterIsInstance().single()
+ assertEquals("freeTrial_start", event.rawName)
+ assertEquals("test_product", event.product.fullIdentifier)
+ assertEquals(7, event.product.trialPeriodDays)
+ assertEquals("2026-09-14T12:30:00.000Z", event.product.trialPeriodEndDateString)
+ val params = event.getSuperwallParameters()
+ assertEquals("test_product", params["product_id"])
+ assertEquals("active_paywall", params["paywall_identifier"])
+ assertEquals("active_experiment", params["experiment_id"])
+ assertEquals("active_variant", params["variant_id"])
+ assertEquals("7", params["product_trial_period_days"])
+ assertEquals("$0.00", params["product_trial_period_price"])
+ }
+
+ @Test
+ fun `legacy response without product still unlocks and calls delegate`() =
+ runTest {
+ changeProduct { null }
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(result) }
+ verify { factory.internallySetSubscriptionStatus(SubscriptionStatus.Active(response.customerInfo!!.entitlements.toSet())) }
+ }
+
+ @Test
+ fun `zero trial days skip trial side effects`() =
+ runTest {
+ changeProduct { it.copy(trialPeriodDays = 0) }
+ redeem()
+ assertNoTrialSideEffects()
+ }
+
+ @Test
+ fun `ineligible paywall skips trial side effects`() =
+ runTest {
+ paywallInfo = paywallInfo.copy(isFreeTrialAvailable = false)
+ redeem()
+ assertNoTrialSideEffects()
+ }
+
+ @Test
+ fun `no active paywall still delivers the full product`() =
+ runTest {
+ visible = false
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(result) }
+ assertEquals(
+ 7,
+ result.redemptionInfo.paywallInfo!!
+ .product!!
+ .trialPeriodDays,
+ )
+ }
+
+ @Test
+ fun `no trial reminders still tracks trial start`() =
+ runTest {
+ paywallInfo = paywallInfo.copy(localNotifications = listOf(reminder.copy(type = LocalNotificationType.Unsupported)))
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ }
+
+ @Test
+ fun `background redemption refreshes never repeat trial side effects`() =
+ runTest {
+ redeem(WebPaywallRedeemer.RedeemType.Existing)
+ redeem(WebPaywallRedeemer.RedeemType.IntegrationAttributes)
+ assertNoTrialSideEffects()
+ verify(exactly = 0) { factory.didRedeemLink(any()) }
+ }
+
+ @Test
+ fun `failed code skips side effects even if another code has a trial`() =
+ runTest {
+ response =
+ WebRedemptionResponse(
+ customerInfo = response.customerInfo,
+ codes = listOf(result.copy(code = "OTHER"), RedemptionResult.Error("TESTCODE", ErrorInfo("failed"))),
+ )
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(response.codes.last()) }
+ }
+
+ @Test
+ fun `missing requested code returns error without using another products trial`() =
+ runTest {
+ response = WebRedemptionResponse(customerInfo = response.customerInfo, codes = listOf(result.copy(code = "OTHER")))
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(match { it is RedemptionResult.Error && it.code == "TESTCODE" }) }
+ }
+
+ @Test
+ fun `scheduling failure preserves trial event access and callback`() =
+ runTest {
+ coEvery { factory.scheduleTrialNotifications(any()) } throws IllegalStateException("scheduler unavailable")
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ verify { factory.didRedeemLink(result) }
+ assertTrue(order.indexOf("access") < order.indexOf("callback"))
+ }
+
+ @Test
+ fun `tracking failure still schedules reminders and delivers callback`() =
+ runTest {
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } throws
+ IllegalStateException("tracking unavailable")
+ redeem()
+ coVerify(exactly = 1) { factory.scheduleTrialNotifications(any()) }
+ verify { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `permission wait grants access immediately and defers dismissal until resolved`() =
+ runTest {
+ val permissionResult = CompletableDeferred()
+ coEvery { factory.scheduleTrialNotifications(any()) } coAnswers { permissionResult.await() }
+ val job = launch { redeem() }
+ runCurrent()
+ verify { factory.internallySetSubscriptionStatus(SubscriptionStatus.Active(response.customerInfo!!.entitlements.toSet())) }
+ coVerify(exactly = 0) { factory.triggerRestoreInPaywall() }
+ verify(exactly = 0) { factory.closePaywallIfExists() }
+ permissionResult.complete(Unit)
+ job.join()
+ verify(exactly = 1) { factory.didRedeemLink(result) }
+ verify(exactly = 1) { factory.closePaywallIfExists() }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebTrialReminderTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebTrialReminderTest.kt
new file mode 100644
index 000000000..f4da7c94f
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/web/WebTrialReminderTest.kt
@@ -0,0 +1,41 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import org.junit.Assert.*
+import org.junit.Test
+
+class WebTrialReminderTest {
+ private val checkout = 1788784200000L
+ private val day = 86_400_000L
+ private val product = PaywallProduct("web", trialPeriodDays = 7, trialPeriodEndDate = "2026-09-14T12:30:00Z")
+
+ @Test
+ fun `late redemption subtracts elapsed time from reminder delay`() {
+ assertEquals(5 * day, webTrialReminderDelay(product, 6 * day, checkout + day))
+ assertEquals(6 * day, webTrialReminderDelay(product, 6 * day, checkout))
+ }
+
+ @Test
+ fun `offset timestamps refer to the same instant`() {
+ assertEquals(
+ 5 * day,
+ webTrialReminderDelay(product.copy(trialPeriodEndDate = "2026-09-14T14:30:00+02:00"), 6 * day, checkout + day),
+ )
+ }
+
+ @Test
+ fun `past reminders and reminders at or after conversion are skipped`() {
+ assertNull(webTrialReminderDelay(product, day, checkout + 2 * day))
+ assertNull(webTrialReminderDelay(product, 7 * day, checkout))
+ assertNull(webTrialReminderDelay(product, 8 * day, checkout))
+ assertNull(webTrialReminderDelay(product, 6 * day, checkout + 8 * day))
+ }
+
+ @Test
+ fun `ambiguous invalid or overflowing dates are safe to skip`() {
+ for (end in listOf("", "2026-09-14", "September 14, 2026", "invalid", "+999999999-09-14T12:30:00Z")) {
+ assertNull(webTrialReminderDelay(product.copy(trialPeriodEndDate = end), day, checkout))
+ }
+ assertNull(webTrialReminderDelay(product, Long.MAX_VALUE, checkout))
+ }
+}
diff --git a/superwall/src/test/resources/web-redemption-trial.json b/superwall/src/test/resources/web-redemption-trial.json
new file mode 100644
index 000000000..6a3adfdf5
--- /dev/null
+++ b/superwall/src/test/resources/web-redemption-trial.json
@@ -0,0 +1,84 @@
+{
+ "codes": [
+ {
+ "status": "SUCCESS",
+ "code": "TESTCODE",
+ "redemptionInfo": {
+ "ownership": {
+ "type": "APP_USER",
+ "appUserId": "appUserId"
+ },
+ "purchaserInfo": {
+ "appUserId": "appUserId",
+ "storeIdentifiers": {
+ "store": "STRIPE",
+ "stripeCustomerId": "cus_123",
+ "stripeSubscriptionIds": [
+ "sub_123"
+ ]
+ }
+ },
+ "paywallInfo": {
+ "identifier": "test_paywall",
+ "placementName": "test_placement",
+ "placementParams": {},
+ "variantId": "variant_1",
+ "experimentId": "exp_1",
+ "product": {
+ "identifier": "test_product",
+ "languageCode": "en",
+ "locale": "en_US",
+ "currencyCode": "USD",
+ "currencySymbol": "$",
+ "period": "1 month",
+ "periodly": "monthly",
+ "localizedPeriod": "month",
+ "periodAlt": "mo",
+ "periodDays": 30,
+ "periodWeeks": 4,
+ "periodMonths": 1,
+ "periodYears": 0,
+ "rawPrice": 9.99,
+ "price": "$9.99",
+ "dailyPrice": "$0.33",
+ "weeklyPrice": "$2.50",
+ "monthlyPrice": "$9.99",
+ "yearlyPrice": "$119.88",
+ "rawTrialPeriodPrice": 0.0,
+ "trialPeriodPrice": "$0.00",
+ "trialPeriodDailyPrice": "$0.00",
+ "trialPeriodWeeklyPrice": "$0.00",
+ "trialPeriodMonthlyPrice": "$0.00",
+ "trialPeriodYearlyPrice": "$0.00",
+ "trialPeriodDays": 7,
+ "trialPeriodWeeks": 1,
+ "trialPeriodMonths": 0,
+ "trialPeriodYears": 0,
+ "trialPeriodText": "7-day free trial",
+ "trialPeriodEndDate": "2026-09-14T12:30:00.000Z"
+ },
+ "productIdentifier": "test_product"
+ },
+ "entitlements": [
+ {
+ "identifier": "premium",
+ "type": "SERVICE_LEVEL",
+ "isActive": true
+ }
+ ]
+ }
+ }
+ ],
+ "customerInfo": {
+ "subscriptions": [],
+ "nonSubscriptions": [],
+ "userId": "appUserId",
+ "entitlements": [
+ {
+ "identifier": "premium",
+ "type": "SERVICE_LEVEL",
+ "isActive": true
+ }
+ ]
+ }
+}
diff --git a/version.env b/version.env
index 8365f7172..264f00735 100644
--- a/version.env
+++ b/version.env
@@ -1 +1 @@
-SUPERWALL_VERSION=2.8.2
+SUPERWALL_VERSION=2.8.3