From 6d7666a5a74fcff38c7ef6ec6f63ecdd76ee39ca Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 7 Sep 2026 13:21:32 +0200 Subject: [PATCH 01/28] Fix product reference names restored from cached config --- .../CachedProductPricesWebViewTest.kt | 89 +++++++++++ .../sdk/models/product/ProductItem.kt | 7 +- .../CachedProductPricesReproductionTest.kt | 151 ++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt 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..010b75103 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt @@ -0,0 +1,89 @@ +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/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/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..0e2bf8062 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt @@ -0,0 +1,151 @@ +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 + } + } +} From dfc1c8e5a534cbf55f01cd42b9a9c3050446d3df Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 7 Sep 2026 13:22:31 +0200 Subject: [PATCH 02/28] Document cached product price fix in unreleased changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e98d66d1d..d9f770821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## Unreleased + +- Fix prices not showing when product/offers are fetched from cache + ## 2.8.2 ## Enhancements From 7af3aabb3c637d3bdd300505bd35da0de20180a7 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 7 Sep 2026 13:23:58 +0200 Subject: [PATCH 03/28] Format cached product price regression tests --- .../CachedProductPricesWebViewTest.kt | 48 +++-- .../CachedProductPricesReproductionTest.kt | 173 ++++++++++++------ 2 files changed, 147 insertions(+), 74 deletions(-) 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 index 010b75103..022c7211a 100644 --- 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 @@ -39,20 +39,28 @@ class CachedProductPricesWebViewTest { } private fun renderOfferPrice(cached: Boolean): String { - val item = json.decodeFromString( - """{"reference_name":"offer","store_product":{"store":"PLAY_STORE", + 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 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) @@ -61,22 +69,30 @@ class CachedProductPricesWebViewTest { 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.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/", """ + "https://localhost/", + """ - """.trimIndent(), "text/html", "UTF-8", null, + """.trimIndent(), + "text/html", + "UTF-8", + null, ) } try { 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 index 0e2bf8062..701ddac56 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt @@ -32,18 +32,20 @@ import org.junit.Test 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 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", + 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)) @@ -54,7 +56,12 @@ class CachedProductPricesReproductionTest { @Test fun `product reference name survives serialization`() { - val original = freshConfig().paywalls.single().productItems.last() + 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) @@ -63,83 +70,133 @@ class CachedProductPricesReproductionTest { @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 }) + 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())) - } + 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 + 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())) } - 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())) - } + 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) + assertEquals( + prices[index], + variables[index] + .jsonObject[name]!! + .jsonObject["price"]!! + .jsonPrimitive.content, + ) } } - private inner class Harness(var config: Config) { + 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), - ) + 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]) - }) + 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)) + 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 { From e3c3aa6cc25e7ed594beac88ae69545b25a17daa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Sep 2026 11:41:54 +0000 Subject: [PATCH 04/28] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 2f2376c36..01318ccb1 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.4% \ No newline at end of file +branches36.5% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 9076cc635..924227022 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.6% \ No newline at end of file +coverage45.8% \ No newline at end of file From 70c772dfb1991b631d03491c2665476bd183069f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:27:46 +0200 Subject: [PATCH 05/28] fix: convert a JSON null to null, not to the text "null" `JsonElement.toPassableValue` matched `JsonNull` against its `is JsonPrimitive` branch, where every check failed and it fell to the catch-all that reads `content` - which for a null is the string "null". An audience filter asking `field == null` never matched, while `field == "null"` did. The sibling converter `convertFromJsonElement` already handles this, so the two now agree. Two existing assertions in JsonElementToPassableValueTest pinned the old behaviour and are updated. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../rule_logic/cel/SuperscriptEvaluator.kt | 6 ++++++ .../rule_logic/JsonElementToPassableValueTest.kt | 14 ++++++++++---- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f770821..d1f68eae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased - Fix prices not showing when product/offers are fetched from cache +- 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 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/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 From 23ed5145c9726fc0a9534297f52cf24d3af47c8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Sep 2026 13:46:37 +0000 Subject: [PATCH 06/28] Update coverage badge [skip ci] --- .github/badges/jacoco.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 924227022..eb67a0eb4 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.8% \ No newline at end of file +coverage45.7% \ No newline at end of file From a08b0448f9660a92cc493c9418c688c3f3e461d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20T=C3=B6r?= Date: Mon, 7 Sep 2026 15:48:53 +0200 Subject: [PATCH 07/28] Add `pullfrog.yml` workflow --- .github/workflows/pullfrog.yml | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/workflows/pullfrog.yml 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" From ddb61c8d7e2d8e1add91f75fdc1e2af0c13d5788 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 7 Sep 2026 15:48:10 +0200 Subject: [PATCH 08/28] Fix web redemption product details and trial handling --- CHANGELOG.md | 1 + .../sdk/dependencies/DependencyContainer.kt | 15 ++ .../sdk/models/internal/WebRedemption.kt | 68 ++++- .../paywall/view/SuperwallPaywallActivity.kt | 21 +- .../sdk/web/RedemptionStoreProduct.kt | 86 ++++++ .../superwall/sdk/web/WebPaywallRedeemer.kt | 99 ++++--- .../view/TrialNotificationPermissionTest.kt | 110 ++++++++ .../sdk/web/RedemptionStoreProductTest.kt | 105 ++++++++ .../sdk/web/WebRedemptionTrialTest.kt | 253 ++++++++++++++++++ .../test/resources/web-redemption-trial.json | 84 ++++++ 10 files changed, 789 insertions(+), 53 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt create mode 100644 superwall/src/test/resources/web-redemption-trial.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f770821..57b22616f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased +- Web purchase redemption now exposes the full checkout product in `didRedeemLink`, tracks `freeTrial_start` for eligible trials, and schedules the active paywall's trial reminders when notification permission is granted. - Fix prices not showing when product/offers are fetched from cache ## 2.8.2 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..a77b66448 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,18 @@ 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) { + activity.attemptToScheduleNotifications(notifications, this@DependencyContainer) + } + } + } + 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..bcf9783dd 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,60 @@ 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, - ) + data 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, + /** Product variables captured at web checkout, including the original trial details. */ + @SerialName("product") + val product: PaywallProduct? = null, + ) { + @Serializable + data class PaywallProduct( + val identifier: String, + val languageCode: String = "", + val locale: String = "", + val currencyCode: String = "", + val currencySymbol: String = "", + val period: String = "", + val periodly: String = "", + val localizedPeriod: String = "", + val periodAlt: String = "", + val periodDays: Int = 0, + val periodWeeks: Int = 0, + val periodMonths: Int = 0, + val periodYears: Int = 0, + val rawPrice: Double = 0.0, + val price: String = "", + val dailyPrice: String = "", + val weeklyPrice: String = "", + val monthlyPrice: String = "", + val yearlyPrice: String = "", + val rawTrialPeriodPrice: Double = 0.0, + val trialPeriodPrice: String = "", + val trialPeriodDailyPrice: String = "", + val trialPeriodWeeklyPrice: String = "", + val trialPeriodMonthlyPrice: String = "", + val trialPeriodYearlyPrice: String = "", + val trialPeriodDays: Int = 0, + val trialPeriodWeeks: Int = 0, + val trialPeriodMonths: Int = 0, + val trialPeriodYears: Int = 0, + val trialPeriodText: String = "", + val trialPeriodEndDate: String = "", + ) + } } @Serializable 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..1558afde8 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 @@ -66,6 +66,7 @@ import kotlinx.coroutines.launch import java.lang.ref.WeakReference import java.util.UUID import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine class SuperwallPaywallActivity : AppCompatActivity() { @@ -912,13 +913,19 @@ class SuperwallPaywallActivity : AppCompatActivity() { notificationPermissionCallback = object : NotificationPermissionCallback { override fun onPermissionResult(granted: Boolean) { - if (granted) { - NotificationScheduler.scheduleNotifications( - notifications = notifications, - factory = factory, - context = this@SuperwallPaywallActivity, - cancelExisting = cancelExisting, - ) + try { + if (granted) { + NotificationScheduler.scheduleNotifications( + notifications = notifications, + factory = factory, + context = this@SuperwallPaywallActivity, + cancelExisting = cancelExisting, + ) + } + } catch (e: Exception) { + // Deliver asynchronous permission-callback failures to the awaiting redemption. + continuation.resumeWithException(e) + return } continuation.resume(Unit) // Resume coroutine after processing } 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..6fde5298b --- /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.Instant +import org.threeten.bp.LocalDate +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. + try { + Date(Instant.parse(product.trialPeriodEndDate).toEpochMilli()) + } catch (_: DateTimeParseException) { + try { + Date( + LocalDate + .parse(product.trialPeriodEndDate) + .atStartOfDay() + .toInstant(ZoneOffset.UTC) + .toEpochMilli(), + ) + } catch (_: DateTimeParseException) { + 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..a72f55e02 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -23,13 +23,17 @@ 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.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.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 @@ -90,6 +94,8 @@ class WebPaywallRedeemer( fun closePaywallIfExists() fun isPaymentSheetOpen(): Boolean + + suspend fun scheduleTrialNotifications(notifications: List) {} } private var pollingJob: Job? = null @@ -223,6 +229,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 +262,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 +282,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 +326,44 @@ 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 + val paywallInfo = factory.getPaywallInfo() + if (!paywallInfo.isFreeTrialAvailable) return + + attemptTrialSideEffect("track web free trial start") { + track(InternalSuperwallEvent.FreeTrialStart(paywallInfo, StoreProduct(RedemptionStoreProduct(product)))) + } + val notifications = + paywallInfo.localNotifications + .filter { it.type == LocalNotificationType.TrialStarted } + .map { it.copy(id = "${paywallInfo.identifier}_${it.type.raw}") } + if (notifications.isNotEmpty()) { + attemptTrialSideEffect("schedule web trial notifications") { + factory.scheduleTrialNotifications(notifications) + } + } + } + + 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/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..38ac150e1 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt @@ -0,0 +1,110 @@ +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.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +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.annotation.Config + +@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 + } + + @After + fun tearDown() { + unmockkObject(NotificationScheduler.Companion) + } + + @Test + fun `granted notification permission schedules the reminders`() = + runTest { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + activity.attemptToScheduleNotifications(notifications, factory) + verify(exactly = 1) { NotificationScheduler.scheduleNotifications(notifications, factory, activity, false) } + } + + @Test + fun `denied permission completes the attempt without scheduling`() = + runTest { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val job = launch { activity.attemptToScheduleNotifications(notifications, factory) } + runCurrent() + assertFalse(job.isCompleted) + val request = shadowOf(activity).lastRequestedPermission + activity.onRequestPermissionsResult( + request.requestCode, + request.requestedPermissions, + intArrayOf(PackageManager.PERMISSION_DENIED), + ) + 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 app = ApplicationProvider.getApplicationContext() + shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + 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() + val request = shadowOf(activity).lastRequestedPermission + activity.onRequestPermissionsResult( + request.requestCode, + request.requestedPermissions, + intArrayOf(PackageManager.PERMISSION_GRANTED), + ) + runCurrent() + assertTrue(job.isCompleted) + assertSame(failure, received) + } +} 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..6b80afb9f --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt @@ -0,0 +1,105 @@ +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 `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 deprecated 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) + for (value in listOf("", "not a date")) { + 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..59ec8592c --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt @@ -0,0 +1,253 @@ +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.network.Network +import com.superwall.sdk.paywall.presentation.PaywallInfo +import com.superwall.sdk.storage.LatestRedemptionResponse +import com.superwall.sdk.storage.Storage +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.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", + isFreeTrialAvailable = true, + localNotifications = listOf(reminder), + ) + 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.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 = + response.copy( + 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 `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("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 = response.copy(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 = response.copy(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/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 + } + ] + } +} From fb7b278cd010bd0dc7b24f4995510fb02f955708 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Sep 2026 14:54:12 +0000 Subject: [PATCH 09/28] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 01318ccb1..fe590d387 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.5% \ No newline at end of file +branches37.3% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 924227022..d578d6f41 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.8% \ No newline at end of file +coverage46.4% \ No newline at end of file From e7d92897cc25e7403ab938abf8da464b972c9f5c Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 8 Sep 2026 14:44:08 +0200 Subject: [PATCH 10/28] Harden web trial redemption lifecycle, timing, and compatibility --- CHANGELOG.md | 1 + superwall/build.gradle.kts | 4 + .../sdk/dependencies/DependencyContainer.kt | 8 +- .../sdk/models/internal/WebRedemption.kt | 118 +++++++++++++++--- .../paywall/view/SuperwallPaywallActivity.kt | 62 +++++++-- .../com/superwall/sdk/storage/CacheKeys.kt | 6 + .../notifications/NotificationScheduler.kt | 3 +- .../superwall/sdk/web/WebPaywallRedeemer.kt | 29 ++++- .../com/superwall/sdk/web/WebTrialReminder.kt | 27 ++++ .../view/TrialNotificationPermissionTest.kt | 111 +++++++++++++++- .../sdk/web/RedemptionStoreProductTest.kt | 61 ++++++++- .../sdk/web/WebRedemptionTrialTest.kt | 99 ++++++++++++++- .../superwall/sdk/web/WebTrialReminderTest.kt | 41 ++++++ 13 files changed, 530 insertions(+), 40 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/web/WebTrialReminder.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/web/WebTrialReminderTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b22616f..2c7aaa680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased - Web purchase redemption now exposes the full checkout product in `didRedeemLink`, tracks `freeTrial_start` for eligible trials, and schedules the active paywall's trial reminders when notification permission is granted. +- Web trial redemption now bounds notification permission waits, deduplicates emitted trial events across launches, and adjusts reminder timing from precise checkout end timestamps. Reminders with ambiguous dates or times already elapsed are skipped. Existing Kotlin constructor and copy signatures are preserved. - Fix prices not showing when product/offers are fetched from cache ## 2.8.2 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/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index a77b66448..fe6b0d72a 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -1318,7 +1318,13 @@ class DependencyContainer( (paywallView.encapsulatingActivity?.get() ?: activityProvider?.getCurrentActivity()) as? SuperwallPaywallActivity ?: return@withContext if (!activity.isFinishing && !activity.isDestroyed) { - activity.attemptToScheduleNotifications(notifications, this@DependencyContainer) + // Web reminders already use an absolute checkout timestamp, including in sandbox. + activity.attemptToScheduleNotifications( + notifications, + this@DependencyContainer, + cancelExisting = false, + applySandboxScaling = 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 bcf9783dd..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,57 +125,143 @@ sealed class RedemptionResult { ) : RedemptionResult() @Serializable - data class PaywallInfo + 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, - /** Product variables captured at web checkout, including the original trial details. */ - @SerialName("product") - val product: PaywallProduct? = null, + @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 = "", ) } 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 1558afde8..23cbfc29a 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,11 +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.resumeWithException -import kotlin.coroutines.suspendCoroutine class SuperwallPaywallActivity : AppCompatActivity() { companion object { @@ -826,6 +827,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { } override fun onDestroy() { + notificationPermissionCallback?.onPermissionResult(false) super.onDestroy() val content = contentView as? ViewGroup? @@ -902,25 +904,49 @@ 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 (notificationPermissionCallback === this) notificationPermissionCallback = null + if (!continuation.isActive) return try { if (granted) { - NotificationScheduler.scheduleNotifications( - notifications = notifications, - factory = factory, - context = this@SuperwallPaywallActivity, - cancelExisting = cancelExisting, - ) + // Web delays have already been anchored to checkout; permission time 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.isNotEmpty()) { + NotificationScheduler.scheduleNotifications( + notifications = readyNotifications, + factory = factory, + context = this@SuperwallPaywallActivity, + cancelExisting = cancelExisting, + applySandboxScaling = applySandboxScaling, + ) + } } } catch (e: Exception) { // Deliver asynchronous permission-callback failures to the awaiting redemption. @@ -930,8 +956,18 @@ class SuperwallPaywallActivity : AppCompatActivity() { continuation.resume(Unit) // Resume coroutine after processing } } - - checkAndRequestNotificationPermissions(this, notificationPermissionCallback!!) + notificationPermissionCallback = callback + continuation.invokeOnCancellation { + runOnUiThread { + if (notificationPermissionCallback === callback) notificationPermissionCallback = null + } + } + try { + checkAndRequestNotificationPermissions(this, callback) + } catch (e: Exception) { + if (notificationPermissionCallback === callback) notificationPermissionCallback = null + if (continuation.isActive) continuation.resumeWithException(e) + } } private fun createNotificationChannel() { 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/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt index a72f55e02..7370262d0 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -31,6 +31,7 @@ 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 @@ -39,11 +40,16 @@ 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, @@ -96,10 +102,13 @@ class WebPaywallRedeemer( fun isPaymentSheetOpen(): Boolean suspend fun scheduleTrialNotifications(notifications: List) {} + + fun currentTimeMillis(): Long = System.currentTimeMillis() } private var pollingJob: Job? = null private var redemptionJob: Job? = null + private val trialTrackingMutex = Mutex() private suspend fun track(event: Trackable) = factory.track(event) @@ -329,19 +338,33 @@ class WebPaywallRedeemer( 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") { - track(InternalSuperwallEvent.FreeTrialStart(paywallInfo, StoreProduct(RedemptionStoreProduct(product)))) + 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 notifications = paywallInfo.localNotifications .filter { it.type == LocalNotificationType.TrialStarted } - .map { it.copy(id = "${paywallInfo.identifier}_${it.type.raw}") } + .mapNotNull { notification -> + webTrialReminderDelay(product, notification.delay, factory.currentTimeMillis())?.let { delay -> + notification.copy(id = "${paywallInfo.identifier}_${notification.type.raw}", delay = delay) + } + } if (notifications.isNotEmpty()) { attemptTrialSideEffect("schedule web trial notifications") { - factory.scheduleTrialNotifications(notifications) + withTimeoutOrNull(WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS) { + factory.scheduleTrialNotifications(notifications) + } } } } 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/view/TrialNotificationPermissionTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt index 38ac150e1..3c5e8bd44 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt @@ -15,12 +15,13 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject import io.mockk.verify +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch 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.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -29,6 +30,8 @@ import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSystemClock +import java.time.Duration @RunWith(RobolectricTestRunner::class) @Config(sdk = [33], manifest = Config.NONE) @@ -49,6 +52,109 @@ class TrialNotificationPermissionTest { unmockkObject(NotificationScheduler.Companion) } + @Test + fun `permission wait is subtracted from absolute web reminder delay`() = + runTest { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } just Runs + val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val job = launch { activity.attemptToScheduleNotifications(notifications, factory, false, false) } + runCurrent() + ShadowSystemClock.advanceBy(Duration.ofSeconds(10)) + val request = shadowOf(activity).lastRequestedPermission + activity.onRequestPermissionsResult( + request.requestCode, + request.requestedPermissions, + intArrayOf(PackageManager.PERMISSION_GRANTED), + ) + 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 app = ApplicationProvider.getApplicationContext() + shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val job = + launch { + activity.attemptToScheduleNotifications( + listOf(notifications.single().copy(delay = 5_000L)), + factory, + false, + false, + ) + } + runCurrent() + ShadowSystemClock.advanceBy(Duration.ofSeconds(10)) + val request = shadowOf(activity).lastRequestedPermission + activity.onRequestPermissionsResult( + request.requestCode, + request.requestedPermissions, + intArrayOf(PackageManager.PERMISSION_GRANTED), + ) + job.join() + verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelled permission wait ignores late and duplicate results`() = + runTest { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val job = launch { activity.attemptToScheduleNotifications(notifications, factory) } + runCurrent() + val request = shadowOf(activity).lastRequestedPermission + job.cancelAndJoin() + repeat(2) { + activity.onRequestPermissionsResult( + request.requestCode, + request.requestedPermissions, + intArrayOf(PackageManager.PERMISSION_GRANTED), + ) + } + verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } + } + + @Test + fun `destroyed activity releases the permission waiter`() = + runTest { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + val controller = Robolectric.buildActivity(SuperwallPaywallActivity::class.java) + val activity = controller.get() + activity.setTheme(androidx.appcompat.R.style.Theme_AppCompat) + controller.create() + val job = launch { activity.attemptToScheduleNotifications(notifications, factory) } + runCurrent() + controller.destroy() + runCurrent() + assertTrue(job.isCompleted) + verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } + } + + @Test + fun `web reminders bypass native sandbox delay scaling`() = + runTest { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } just Runs + val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + 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 { @@ -105,6 +211,7 @@ class TrialNotificationPermissionTest { ) runCurrent() assertTrue(job.isCompleted) - assertSame(failure, received) + assertTrue(received is IllegalStateException) + assertEquals(failure.message, received?.message) } } diff --git a/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt index 6b80afb9f..1ea77b638 100644 --- a/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt @@ -25,6 +25,65 @@ class RedemptionStoreProductTest { 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 = @@ -72,7 +131,7 @@ class RedemptionStoreProductTest { } @Test - fun `product is retained without the deprecated identifier`() { + 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) diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt index 59ec8592c..d1bb51199 100644 --- a/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt @@ -14,10 +14,12 @@ 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 @@ -29,6 +31,7 @@ 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 @@ -53,9 +56,16 @@ class WebRedemptionTrialTest { 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 @@ -67,6 +77,9 @@ class WebRedemptionTrialTest { 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() @@ -99,7 +112,8 @@ class WebRedemptionTrialTest { private fun changeProduct(transform: (PaywallProduct) -> PaywallProduct?) { val info = result.redemptionInfo response = - response.copy( + WebRedemptionResponse( + customerInfo = response.customerInfo, codes = listOf( result.copy( @@ -117,6 +131,78 @@ class WebRedemptionTrialTest { 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 `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 { @@ -134,6 +220,9 @@ class WebRedemptionTrialTest { 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"]) } @@ -200,7 +289,11 @@ class WebRedemptionTrialTest { @Test fun `failed code skips side effects even if another code has a trial`() = runTest { - response = response.copy(codes = listOf(result.copy(code = "OTHER"), RedemptionResult.Error("TESTCODE", ErrorInfo("failed")))) + response = + WebRedemptionResponse( + customerInfo = response.customerInfo, + codes = listOf(result.copy(code = "OTHER"), RedemptionResult.Error("TESTCODE", ErrorInfo("failed"))), + ) redeem() assertNoTrialSideEffects() verify { factory.didRedeemLink(response.codes.last()) } @@ -209,7 +302,7 @@ class WebRedemptionTrialTest { @Test fun `missing requested code returns error without using another products trial`() = runTest { - response = response.copy(codes = listOf(result.copy(code = "OTHER"))) + response = WebRedemptionResponse(customerInfo = response.customerInfo, codes = listOf(result.copy(code = "OTHER"))) redeem() assertNoTrialSideEffects() verify { factory.didRedeemLink(match { it is RedemptionResult.Error && it.code == "TESTCODE" }) } 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)) + } +} From 5415a6f2ffc3b04965e8218961cb202956958dca Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 10 Sep 2026 13:47:37 +0200 Subject: [PATCH 11/28] Pause/play media playback on resume/pause, avoid preloaded videos playing --- CHANGELOG.md | 1 + .../superwall/superapp/test/UITestHandler.kt | 2 - .../src/androidTest/assets/media-playback.mp4 | Bin 0 -> 2863 bytes .../view/webview/MediaPlaybackScriptTest.kt | 133 ++++++++++++++++++ .../paywall/view/SuperwallPaywallActivity.kt | 2 + .../view/webview/MediaPlaybackScript.kt | 41 ++++++ .../sdk/paywall/view/webview/PaywallWebUI.kt | 4 + .../sdk/paywall/view/webview/SWWebView.kt | 50 ++++++- .../paywall/view/PaywallMessageHandlerTest.kt | 4 + .../sdk/paywall/view/PaywallViewTest.kt | 4 + .../webview/SWWebViewMediaLifecycleTest.kt | 75 ++++++++++ 11 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 superwall/src/androidTest/assets/media-playback.mp4 create mode 100644 superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScriptTest.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/webview/MediaPlaybackScript.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f770821..0dbda02d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased - Fix prices not showing when product/offers are fetched from cache +- Fix video loading and playing in the background on preloaded paywalls ## 2.8.2 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/src/androidTest/assets/media-playback.mp4 b/superwall/src/androidTest/assets/media-playback.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..e5c1ccc74734baee583e7a51379c415b5b1efb27 GIT binary patch literal 2863 zcmeHIPiP!f7=PO&HIx#owAe$Cr{!6ZIl*7FCMKFX%WOyP;XYmtiNw&l9`6$pMwX%M`pjjzwh_n z_szU8#&}D0BQFU9#`Z9hcnq;>>p@i485?>t2*Xvzm>;aV4rFN!{J_|4U$F|Kzhd2F zz`e_Uq{zILwuiC7k5nv{5PVTBrJMt$#Jn1KcduPMx8ywl5Iu~INk=7+qtZ`Q>PdG> zbqDi357q+56NvG^*?o2cZihX4Aw^EOjvwbW*sABqZJp#X*x`a`JHDixfo}t^-GXv( zHAst=w>{=$u~8?MltYW+`&VM0XZk=dQB5EECy7e72SPb#XlRhM$~8030UUb)Dm}C+ z2ah7ZQLE}zUDLVmH9JP5p2f1?!~H&#I}4%^qi%I;c(Jbme<%|~$z>e2xF{Go*t z&sW%9_xg8V-n;qxjT?kL5Q!v$q+QT+u&Ls_#fQ$prgb<`Y5Pg19P|^DM=nl9W1*Gf zD{tFHF7+LLkf(BAF1KwmDW2AX{6C)cL6#=qv5?)&j4 z`k46yx5HR+y)nh(nqe5cS(|ETQv!j7DiS82cxwK!i8`M-Hit|{+6c^rQP-C(#SKlX zO&FRn1*fZ2G(9=FwzgKK+b#4(yBfxelf+VWRp28hjFcC)lWA^?rm#(&;|4Qp+>uQ` zw3keMTAS9mXbZoaNK@<7rnQc)*LWaJS9W-^(u8(DkCHB+__bok)TO%J%6SJ~}BO*FQ(`tGmArdc^ z>ABXVx9F-SVqqlP)?yeTnmHptmt+^UO=C*SRSW-eVDiM4ZE3G4v#!xLV?iyAW#R%J z+t$u;DIVLV9k+OZyIaz+V3lC=0??i@M z$F7Gf8y}YV&JvE!Ix=(WT!G8^zPX9-*Am}vCES$)m+|%PF7x%w0@r)Ez?~c`a2a23 nw#@gZ^P@S0rTLZT@K^H#F6aAa^Ru@3{^tDj{Cv;#w>JL(dvRM1 literal 0 HcmV?d00001 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/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 95c7762ad..af5f9602e 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 @@ -788,6 +788,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { override fun onResume() { super.onResume() val paywallVc = paywallView() ?: return + paywallVc.webView.onResume() if (isBottomSheetView || isPopupView) { setTransparentBackground() } @@ -801,6 +802,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { super.onPause() val paywallVc = paywallView() ?: return + paywallVc.webView.onPause() mainScope.launch { paywallVc.beforeOnDestroy(forceCleanup = isFinishing) } 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..6c1889eb9 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..049b2dd6c 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,50 @@ 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. + 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 +240,7 @@ class SWWebView( } private val onPageStartedPreloadHook: (WebView) -> Unit = { view -> + updateMediaPlayback() currentDeviceLocale()?.let { locale -> view.evaluateJavascript(DevicePreloadScript.build(locale), null) } @@ -205,7 +250,7 @@ class SWWebView( addJavascriptInterface(messageHandler, "SWAndroid") val webSettings = this.settings - setWebContentsDebuggingEnabled(false) + setWebContentsDebuggingEnabled(true) webSettings.javaScriptEnabled = true webSettings.setSupportZoom(false) webSettings.builtInZoomControls = false @@ -459,6 +504,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 +621,7 @@ class SWWebView( } override fun destroy() { + viewDestroyed = true onScrollChangeListener = null super.destroy() } 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/webview/SWWebViewMediaLifecycleTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt new file mode 100644 index 000000000..1fd2c98eb --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/SWWebViewMediaLifecycleTest.kt @@ -0,0 +1,75 @@ +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) + + 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, + ) + } +} From 358cddfe1ca59a497f3b9774816b54153eb8d59d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Sep 2026 12:06:59 +0000 Subject: [PATCH 12/28] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 01318ccb1..ed294c4a8 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.5% \ No newline at end of file +branches36.6% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index eb67a0eb4..924227022 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.7% \ No newline at end of file +coverage45.8% \ No newline at end of file From 2bb598c468d94f780dedca09352e9b9634595d6c Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 10 Sep 2026 14:15:47 +0200 Subject: [PATCH 13/28] Update superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> --- .../java/com/superwall/sdk/paywall/view/webview/SWWebView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 049b2dd6c..02d480218 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 @@ -250,7 +250,7 @@ class SWWebView( addJavascriptInterface(messageHandler, "SWAndroid") val webSettings = this.settings - setWebContentsDebuggingEnabled(true) + setWebContentsDebuggingEnabled(false) webSettings.javaScriptEnabled = true webSettings.setSupportZoom(false) webSettings.builtInZoomControls = false From ffa1a6624cf0c69d002cb9337b2bdb8fc2129d07 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 10 Sep 2026 15:06:26 +0200 Subject: [PATCH 14/28] Address review: document constructor guard, test detach while resumed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FY2V8fiWgvPpBhfp1Wjmc9 --- .../com/superwall/sdk/paywall/view/webview/SWWebView.kt | 4 +++- .../paywall/view/webview/SWWebViewMediaLifecycleTest.kt | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) 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 02d480218..6bf9c04a8 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 @@ -183,7 +183,9 @@ class SWWebView( private var hostPaused = false private var viewDestroyed = false - // View callbacks can run from the superclass constructor. + // 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() { 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 index 1fd2c98eb..98043f7c0 100644 --- 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 @@ -43,6 +43,13 @@ class SWWebViewMediaLifecycleTest { 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) From a197f3a2d38db48df6b5bffdc16d3974bba93932 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 11:29:49 +0200 Subject: [PATCH 15/28] Fix paywall open nd template ordering --- CHANGELOG.md | 2 + .../superwall/sdk/paywall/view/PaywallView.kt | 2 +- .../messaging/PaywallMessageHandler.kt | 141 ++++-- .../messaging/PaywallMessageOrderingTest.kt | 410 ++++++++++++++++++ 4 files changed, 521 insertions(+), 34 deletions(-) create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f770821..6c951ef87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased +- 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 ## 2.8.2 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/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index 1446d9f89..30cf4e816 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 @@ -32,6 +32,9 @@ import com.superwall.sdk.storage.core_data.convertToJsonElement import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CoroutineScope 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,8 +44,8 @@ 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 interface PaywallStateDelegate { @@ -97,7 +100,88 @@ 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 for the in-flight template send. Other events do not. + private val lifecycleLock = Any() + private var templatesJob: Job? = null + private val queue: Queue = ConcurrentLinkedQueue() + + private fun launchTemplateSend(block: suspend () -> Unit) { + synchronized(lifecycleLock) { + templatesJob = + ioScope.launch { + block() + } + } + } + + private suspend fun awaitInFlightTemplates(snapshot: Job?) { + var job = snapshot + while (true) { + if (job != null && !job.isCompleted) { + job.join() + } + val latest = synchronized(lifecycleLock) { templatesJob } + if (latest == null || latest.isCompleted || latest === job) return + job = latest + } + } + + private fun sendLifecycleWhenReady(pending: PendingLifecycle) { + val toJoin: Job? + synchronized(lifecycleLock) { + if (messageHandler?.state?.paywall?.paywalljsVersion == null) { + queue.offer(pending) + return + } + toJoin = templatesJob + } + ioScope.launch { + awaitInFlightTemplates(toJoin) + 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) { + templatesJob?.cancel() + templatesJob = null + 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 +224,24 @@ 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), + ) + templatesJob = + ioScope.launch { + didLoadWebView(paywall, loadedAt) + } + } } is PaywallMessage.Close -> { @@ -177,26 +266,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 +448,7 @@ class PaywallMessageHandler( ) withContext(Dispatchers.Main) { + currentCoroutineContext().ensureActive() messageHandler?.evaluate(templateScript) { error -> if (error != null) { Logger.debug( @@ -451,7 +524,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 +561,10 @@ class PaywallMessageHandler( } private fun flushPendingMessagesInternal() { - if (queue.isEmpty()) return - - val pending = queue.toList() - queue.clear() - pending.forEach { handle(it) } + while (true) { + val pending = queue.poll() ?: break + sendLifecycleWhenReady(pending) + } } private fun openUrl( 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..8eb696614 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageOrderingTest.kt @@ -0,0 +1,410 @@ +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.delay +import kotlinx.coroutines.test.StandardTestDispatcher +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 buildDelayMs: Long = TEMPLATE_BUILD_MS, + ) : VariablesFactory { + override suspend fun makeJsonVariables( + products: List?, + computedPropertyRequests: List, + event: EventData?, + ): JsonVariables { + delay(buildDelayMs) + 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, + buildDelayMs: Long = TEMPLATE_BUILD_MS, + ): PaywallMessageHandler = + PaywallMessageHandler( + factory = SlowVariablesFactory(fail = failTemplateBuild, buildDelayMs = 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 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 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 = 11_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" + } +} From bc1395edad09e2d2e645bf3b92135b15efafd809 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 12:23:58 +0200 Subject: [PATCH 16/28] Adress PR comments --- .../messaging/PaywallMessageHandler.kt | 79 +++++++++---- .../messaging/PaywallMessageOrderingTest.kt | 107 +++++++++++++++++- 2 files changed, 157 insertions(+), 29 deletions(-) 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 30cf4e816..91a127601 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,6 +31,7 @@ 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 @@ -97,6 +98,9 @@ class PaywallMessageHandler( meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; var head = document.getElementsByTagName('head')[0]; head.appendChild(meta);""" + + // Bound the wait so a hung product/attribute fetch cannot silence paywall_open. + const val TEMPLATE_OPEN_WAIT_MS = 10_000L } var messageHandler: PaywallMessageHandlerDelegate? = null @@ -108,43 +112,65 @@ class PaywallMessageHandler( // 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 for the in-flight template send. Other events do not. + // so open/close wait until every in-flight template send has posted. + // Other events do not wait. private val lifecycleLock = Any() - private var templatesJob: Job? = null + private val inFlightTemplateSends = mutableSetOf() private val queue: Queue = ConcurrentLinkedQueue() private fun launchTemplateSend(block: suspend () -> Unit) { synchronized(lifecycleLock) { - templatesJob = - ioScope.launch { - block() - } + launchTemplateSendLocked(block) } } - private suspend fun awaitInFlightTemplates(snapshot: Job?) { - var job = snapshot - while (true) { - if (job != null && !job.isCompleted) { - job.join() + // 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", + ) + synchronized(lifecycleLock) { + inFlightTemplateSends.toList().forEach { it.cancel() } + inFlightTemplateSends.clear() } - val latest = synchronized(lifecycleLock) { templatesJob } - if (latest == null || latest.isCompleted || latest === job) return - job = latest } } private fun sendLifecycleWhenReady(pending: PendingLifecycle) { - val toJoin: Job? synchronized(lifecycleLock) { if (messageHandler?.state?.paywall?.paywalljsVersion == null) { queue.offer(pending) return } - toJoin = templatesJob } ioScope.launch { - awaitInFlightTemplates(toJoin) + 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. @@ -164,8 +190,8 @@ class PaywallMessageHandler( // the old document and restore an open only if that presentation is still up. internal fun resetForWebViewReload() { synchronized(lifecycleLock) { - templatesJob?.cancel() - templatesJob = null + inFlightTemplateSends.toList().forEach { it.cancel() } + inFlightTemplateSends.clear() queue.clear() val state = messageHandler?.state messageHandler?.updateState(PaywallViewState.Updates.SetPaywallJsVersion(null)) @@ -237,10 +263,9 @@ class PaywallMessageHandler( messageHandler?.updateState( PaywallViewState.Updates.SetPaywallJsVersion(message.paywallJsVersion), ) - templatesJob = - ioScope.launch { - didLoadWebView(paywall, loadedAt) - } + launchTemplateSendLocked { + didLoadWebView(paywall, loadedAt) + } } } @@ -561,10 +586,14 @@ class PaywallMessageHandler( } private fun flushPendingMessagesInternal() { + // 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 pending = queue.poll() ?: break - sendLifecycleWhenReady(pending) + val next = queue.poll() ?: break + pending.add(next) } + pending.forEach { sendLifecycleWhenReady(it) } } private fun openUrl( 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 index 8eb696614..a21a86ca3 100644 --- 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 @@ -93,14 +93,17 @@ class PaywallMessageOrderingTest { private class SlowVariablesFactory( private val fail: Boolean = false, - private val buildDelayMs: Long = TEMPLATE_BUILD_MS, + 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 { - delay(buildDelayMs) + 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())) } @@ -118,9 +121,14 @@ class PaywallMessageOrderingTest { private fun createHandler( failTemplateBuild: Boolean = false, buildDelayMs: Long = TEMPLATE_BUILD_MS, + delaysMs: List? = null, ): PaywallMessageHandler = PaywallMessageHandler( - factory = SlowVariablesFactory(fail = failTemplateBuild, buildDelayMs = buildDelayMs), + factory = + SlowVariablesFactory( + fail = failTemplateBuild, + delaysMs = delaysMs ?: listOf(buildDelayMs), + ), options = object : OptionsFactory { override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions() @@ -227,6 +235,72 @@ class PaywallMessageOrderingTest { } } + @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 { @@ -252,6 +326,31 @@ class PaywallMessageOrderingTest { } } + @Test + fun aHungTemplateBuildDoesNotSilencePaywallOpen() = + runTest { + Given("a template build that never finishes") { + 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(buildDelayMs = 30_000L) + handler.messageHandler = delegate + + When("open waits on that build") { + handler.handle(PaywallMessage.TemplateParamsAndUserAttributes) + handler.handle(PaywallMessage.PaywallOpen) + advanceUntilIdle() + + Then("open is still sent after the wait times out") { + assertTrue( + "paywall_open was lost behind a hung template send", + delegate.evaluations.indexOfMessage(PAYWALL_OPEN) >= 0, + ) + } + } + } + } + @Test fun transactionStartIsNotHeldBehindSlowTemplates() = runTest { @@ -284,7 +383,7 @@ class PaywallMessageOrderingTest { runTest { for (openBeforeReady in listOf(false, true)) { val delegate = RecordingDelegate(PaywallViewState(paywall = Paywall.stub().copy(paywalljsVersion = null), locale = "en-US")) - val handler = createHandler(buildDelayMs = 11_000L) + val handler = createHandler(buildDelayMs = 3_000L) handler.messageHandler = delegate if (openBeforeReady) handler.handle(PaywallMessage.PaywallOpen) From e0171b8524eef40e1dec3dacc9ec5185069b1fe1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 11 Sep 2026 10:50:11 +0000 Subject: [PATCH 17/28] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 01318ccb1..422739d71 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.5% \ No newline at end of file +branches36.7% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 924227022..a08d53e80 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.8% \ No newline at end of file +coverage46% \ No newline at end of file From 379bc9eaf43797122aff118068b630281fbc20d1 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 12:55:35 +0200 Subject: [PATCH 18/28] Fix PR comments on tests --- .../messaging/PaywallMessageHandler.kt | 11 ++--- .../messaging/PaywallMessageOrderingTest.kt | 49 +++++++++++++++++-- 2 files changed, 48 insertions(+), 12 deletions(-) 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 91a127601..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 @@ -49,6 +49,10 @@ 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 @@ -98,9 +102,6 @@ class PaywallMessageHandler( meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; var head = document.getElementsByTagName('head')[0]; head.appendChild(meta);""" - - // Bound the wait so a hung product/attribute fetch cannot silence paywall_open. - const val TEMPLATE_OPEN_WAIT_MS = 10_000L } var messageHandler: PaywallMessageHandlerDelegate? = null @@ -155,10 +156,6 @@ class PaywallMessageHandler( LogScope.paywallView, "Timed out waiting for template_variables; sending lifecycle anyway", ) - synchronized(lifecycleLock) { - inFlightTemplateSends.toList().forEach { it.cancel() } - inFlightTemplateSends.clear() - } } } 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 index a21a86ca3..179f00607 100644 --- 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 @@ -24,8 +24,10 @@ 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 @@ -93,6 +95,7 @@ class PaywallMessageOrderingTest { 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() @@ -102,6 +105,7 @@ class PaywallMessageOrderingTest { 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") @@ -120,6 +124,7 @@ class PaywallMessageOrderingTest { private fun createHandler( failTemplateBuild: Boolean = false, + hangTemplateBuild: Boolean = false, buildDelayMs: Long = TEMPLATE_BUILD_MS, delaysMs: List? = null, ): PaywallMessageHandler = @@ -127,6 +132,7 @@ class PaywallMessageOrderingTest { factory = SlowVariablesFactory( fail = failTemplateBuild, + hang = hangTemplateBuild, delaysMs = delaysMs ?: listOf(buildDelayMs), ), options = @@ -329,23 +335,56 @@ class PaywallMessageOrderingTest { @Test fun aHungTemplateBuildDoesNotSilencePaywallOpen() = runTest { - Given("a template build that never finishes") { + 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(buildDelayMs = 30_000L) + val handler = createHandler(hangTemplateBuild = true) handler.messageHandler = delegate - When("open waits on that build") { + When("open waits on that build past the bound") { handler.handle(PaywallMessage.TemplateParamsAndUserAttributes) handler.handle(PaywallMessage.PaywallOpen) - advanceUntilIdle() + advanceTimeBy(TEMPLATE_OPEN_WAIT_MS + 1) + runCurrent() - Then("open is still sent after the wait times out") { + 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) } } } From cdad9fa16ff55d7b5ba2554f6d331ce3008c9444 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 13:40:06 +0200 Subject: [PATCH 19/28] Fix PR comments --- .../sdk/paywall/view/SuperwallPaywallActivity.kt | 11 ++++------- .../paywall/view/TrialNotificationPermissionTest.kt | 10 ++++++++-- 2 files changed, 12 insertions(+), 9 deletions(-) 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 23cbfc29a..eadcd62e5 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 @@ -925,7 +925,6 @@ class SuperwallPaywallActivity : AppCompatActivity() { object : NotificationPermissionCallback { override fun onPermissionResult(granted: Boolean) { if (notificationPermissionCallback === this) notificationPermissionCallback = null - if (!continuation.isActive) return try { if (granted) { // Web delays have already been anchored to checkout; permission time must not shift them. @@ -950,18 +949,16 @@ class SuperwallPaywallActivity : AppCompatActivity() { } } catch (e: Exception) { // Deliver asynchronous permission-callback failures to the awaiting redemption. - continuation.resumeWithException(e) + if (continuation.isActive) continuation.resumeWithException(e) return } + if (!continuation.isActive) return continuation.resume(Unit) // Resume coroutine after processing } } notificationPermissionCallback = callback - continuation.invokeOnCancellation { - runOnUiThread { - if (notificationPermissionCallback === callback) notificationPermissionCallback = null - } - } + // Keep the callback if the wait is cancelled (redemption timeout). onDestroy and a + // replacement request still clean it up; a late grant can still schedule best-effort. try { checkAndRequestNotificationPermissions(this, callback) } catch (e: Exception) { 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 index 3c5e8bd44..5c5f644fb 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt @@ -108,7 +108,7 @@ class TrialNotificationPermissionTest { } @Test - fun `cancelled permission wait ignores late and duplicate results`() = + fun `cancelled permission wait still schedules a late grant once`() = runTest { val app = ApplicationProvider.getApplicationContext() shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) @@ -124,7 +124,7 @@ class TrialNotificationPermissionTest { intArrayOf(PackageManager.PERMISSION_GRANTED), ) } - verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } + verify(exactly = 1) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } } @Test @@ -138,9 +138,15 @@ class TrialNotificationPermissionTest { controller.create() val job = launch { activity.attemptToScheduleNotifications(notifications, factory) } runCurrent() + val request = shadowOf(activity).lastRequestedPermission controller.destroy() runCurrent() assertTrue(job.isCompleted) + activity.onRequestPermissionsResult( + request.requestCode, + request.requestedPermissions, + intArrayOf(PackageManager.PERMISSION_GRANTED), + ) verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } } From b2969d47be6586641f9836f494be23d026d7d122 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 11 Sep 2026 12:44:47 +0000 Subject: [PATCH 20/28] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 803f64d56..c0c8757f6 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.6% +branches38.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 5034fc64f..f0c8dbed0 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage46% +coverage47.1% \ No newline at end of file From 2901385cb87dc9ce9969c193b0436de2e8f27032 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 14:43:39 +0200 Subject: [PATCH 21/28] Simplify web trial redemption after the permission-wait bound. Drop the instance mutex; persisted codes already dedupe trial events. Keep scheduling on a late grant while the paywall activity is alive, and let teardown own callback cleanup. Co-authored-by: Cursor --- CHANGELOG.md | 3 +- .../paywall/view/SuperwallPaywallActivity.kt | 57 ++++---- .../superwall/sdk/web/WebPaywallRedeemer.kt | 43 +++--- .../view/TrialNotificationPermissionTest.kt | 136 ++++++++---------- 4 files changed, 117 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12daca144..d4a6066a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Unreleased -- Web purchase redemption now exposes the full checkout product in `didRedeemLink`, tracks `freeTrial_start` for eligible trials, and schedules the active paywall's trial reminders when notification permission is granted. -- Web trial redemption now bounds notification permission waits, deduplicates emitted trial events across launches, and adjusts reminder timing from precise checkout end timestamps. Reminders with ambiguous dates or times already elapsed are skipped. Existing Kotlin constructor and copy signatures are preserved. +- 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 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 dd07a88af..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 @@ -929,38 +929,22 @@ class SuperwallPaywallActivity : AppCompatActivity() { if (notificationPermissionCallback === this) notificationPermissionCallback = null try { if (granted) { - // Web delays have already been anchored to checkout; permission time 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.isNotEmpty()) { - NotificationScheduler.scheduleNotifications( - notifications = readyNotifications, - factory = factory, - context = this@SuperwallPaywallActivity, - cancelExisting = cancelExisting, - applySandboxScaling = applySandboxScaling, - ) - } + scheduleGrantedNotifications( + notifications, + factory, + cancelExisting, + applySandboxScaling, + permissionRequestedAt, + ) } } catch (e: Exception) { - // Deliver asynchronous permission-callback failures to the awaiting redemption. if (continuation.isActive) continuation.resumeWithException(e) return } - if (!continuation.isActive) return - continuation.resume(Unit) // Resume coroutine after processing + if (continuation.isActive) continuation.resume(Unit) } } notificationPermissionCallback = callback - // Keep the callback if the wait is cancelled (redemption timeout). onDestroy and a - // replacement request still clean it up; a late grant can still schedule best-effort. try { checkAndRequestNotificationPermissions(this, callback) } catch (e: Exception) { @@ -969,6 +953,31 @@ class SuperwallPaywallActivity : AppCompatActivity() { } } + 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() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val importance = NotificationManager.IMPORTANCE_DEFAULT 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 7370262d0..2d9b2a38f 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -22,6 +22,7 @@ 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 @@ -40,8 +41,6 @@ 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 @@ -108,7 +107,6 @@ class WebPaywallRedeemer( private var pollingJob: Job? = null private var redemptionJob: Job? = null - private val trialTrackingMutex = Mutex() private suspend fun track(event: Trackable) = factory.track(event) @@ -344,31 +342,32 @@ class WebPaywallRedeemer( 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 trackedCodes = storage.read(TrackedWebTrialCodes).orEmpty() + if (result.code !in trackedCodes) { + track(InternalSuperwallEvent.FreeTrialStart(paywallInfo, StoreProduct(RedemptionStoreProduct(product)))) + storage.write(TrackedWebTrialCodes, trackedCodes + result.code) } } - val notifications = - paywallInfo.localNotifications - .filter { it.type == LocalNotificationType.TrialStarted } - .mapNotNull { notification -> - webTrialReminderDelay(product, notification.delay, factory.currentTimeMillis())?.let { delay -> - notification.copy(id = "${paywallInfo.identifier}_${notification.type.raw}", delay = delay) - } - } - if (notifications.isNotEmpty()) { - attemptTrialSideEffect("schedule web trial notifications") { - withTimeoutOrNull(WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS) { - factory.scheduleTrialNotifications(notifications) - } + 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, 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 index 5c5f644fb..1f39c2c69 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt @@ -15,8 +15,10 @@ 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 @@ -29,6 +31,7 @@ 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 @@ -45,6 +48,7 @@ class TrialNotificationPermissionTest { 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 @@ -55,19 +59,10 @@ class TrialNotificationPermissionTest { @Test fun `permission wait is subtracted from absolute web reminder delay`() = runTest { - val app = ApplicationProvider.getApplicationContext() - shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) - every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } just Runs - val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() - val job = launch { activity.attemptToScheduleNotifications(notifications, factory, false, false) } - runCurrent() + val activity = deniedActivity() + val job = launchWait(activity, applySandboxScaling = false) ShadowSystemClock.advanceBy(Duration.ofSeconds(10)) - val request = shadowOf(activity).lastRequestedPermission - activity.onRequestPermissionsResult( - request.requestCode, - request.requestedPermissions, - intArrayOf(PackageManager.PERMISSION_GRANTED), - ) + activity.deliverPermission(granted = true) job.join() verify(exactly = 1) { NotificationScheduler.scheduleNotifications( @@ -83,9 +78,7 @@ class TrialNotificationPermissionTest { @Test fun `reminder that expires during permission wait is skipped`() = runTest { - val app = ApplicationProvider.getApplicationContext() - shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) - val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val activity = deniedActivity() val job = launch { activity.attemptToScheduleNotifications( @@ -97,12 +90,7 @@ class TrialNotificationPermissionTest { } runCurrent() ShadowSystemClock.advanceBy(Duration.ofSeconds(10)) - val request = shadowOf(activity).lastRequestedPermission - activity.onRequestPermissionsResult( - request.requestCode, - request.requestedPermissions, - intArrayOf(PackageManager.PERMISSION_GRANTED), - ) + activity.deliverPermission(granted = true) job.join() verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } } @@ -110,53 +98,30 @@ class TrialNotificationPermissionTest { @Test fun `cancelled permission wait still schedules a late grant once`() = runTest { - val app = ApplicationProvider.getApplicationContext() - shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) - val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() - val job = launch { activity.attemptToScheduleNotifications(notifications, factory) } - runCurrent() - val request = shadowOf(activity).lastRequestedPermission + val activity = deniedActivity() + val job = launchWait(activity) job.cancelAndJoin() - repeat(2) { - activity.onRequestPermissionsResult( - request.requestCode, - request.requestedPermissions, - intArrayOf(PackageManager.PERMISSION_GRANTED), - ) - } + 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 app = ApplicationProvider.getApplicationContext() - shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) - val controller = Robolectric.buildActivity(SuperwallPaywallActivity::class.java) + val controller = deniedActivityController() val activity = controller.get() - activity.setTheme(androidx.appcompat.R.style.Theme_AppCompat) - controller.create() - val job = launch { activity.attemptToScheduleNotifications(notifications, factory) } - runCurrent() - val request = shadowOf(activity).lastRequestedPermission + val job = launchWait(activity) controller.destroy() runCurrent() assertTrue(job.isCompleted) - activity.onRequestPermissionsResult( - request.requestCode, - request.requestedPermissions, - intArrayOf(PackageManager.PERMISSION_GRANTED), - ) + activity.deliverPermission(granted = true) verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } } @Test fun `web reminders bypass native sandbox delay scaling`() = runTest { - val app = ApplicationProvider.getApplicationContext() - shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } just Runs - val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val activity = grantedActivity() activity.attemptToScheduleNotifications(notifications, factory, cancelExisting = false, applySandboxScaling = false) verify(exactly = 1) { NotificationScheduler.scheduleNotifications(notifications, factory, activity, false, false) } } @@ -164,9 +129,7 @@ class TrialNotificationPermissionTest { @Test fun `granted notification permission schedules the reminders`() = runTest { - val app = ApplicationProvider.getApplicationContext() - shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val activity = grantedActivity() activity.attemptToScheduleNotifications(notifications, factory) verify(exactly = 1) { NotificationScheduler.scheduleNotifications(notifications, factory, activity, false) } } @@ -174,18 +137,10 @@ class TrialNotificationPermissionTest { @Test fun `denied permission completes the attempt without scheduling`() = runTest { - val app = ApplicationProvider.getApplicationContext() - shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) - val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() - val job = launch { activity.attemptToScheduleNotifications(notifications, factory) } - runCurrent() + val activity = deniedActivity() + val job = launchWait(activity) assertFalse(job.isCompleted) - val request = shadowOf(activity).lastRequestedPermission - activity.onRequestPermissionsResult( - request.requestCode, - request.requestedPermissions, - intArrayOf(PackageManager.PERMISSION_DENIED), - ) + activity.deliverPermission(granted = false) runCurrent() assertTrue(job.isCompleted) verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } @@ -194,9 +149,7 @@ class TrialNotificationPermissionTest { @Test fun `scheduling failure after permission grant reaches the waiting caller`() = runTest { - val app = ApplicationProvider.getApplicationContext() - shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) - val activity = Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get() + val activity = deniedActivity() val failure = IllegalStateException("WorkManager unavailable") every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } throws failure var received: Exception? = null @@ -209,15 +162,50 @@ class TrialNotificationPermissionTest { } } runCurrent() - val request = shadowOf(activity).lastRequestedPermission - activity.onRequestPermissionsResult( - request.requestCode, - request.requestedPermissions, - intArrayOf(PackageManager.PERMISSION_GRANTED), - ) + 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), + ) + } } From 7028c718fbbf46cca27fe8d46250ca9ccbc09448 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 11 Sep 2026 13:12:15 +0000 Subject: [PATCH 22/28] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index c0c8757f6..7392eda28 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.2% \ No newline at end of file +branches38.1% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index f0c8dbed0..8e021ed1c 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.1% \ No newline at end of file +coverage47% \ No newline at end of file From 049eb40b3548812580e6b79dac33f9f2100b713e Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 15:27:24 +0200 Subject: [PATCH 23/28] Restore trial-event mutex for overlapping same-code redemptions. Code redemptions launch independently, so the persisted set alone cannot stop two in-flight calls from both seeing an empty marker across track(). Hold the lock through that write so freeTrial_start stays once per code. Co-authored-by: Cursor --- .../superwall/sdk/web/WebPaywallRedeemer.kt | 15 ++++++++--- .../sdk/web/WebRedemptionTrialTest.kt | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) 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 2d9b2a38f..d9b5d6e1b 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -41,6 +41,8 @@ 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 @@ -107,6 +109,9 @@ class WebPaywallRedeemer( 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) @@ -342,10 +347,12 @@ class WebPaywallRedeemer( if (!paywallInfo.isFreeTrialAvailable) return attemptTrialSideEffect("track web free trial start") { - val trackedCodes = storage.read(TrackedWebTrialCodes).orEmpty() - if (result.code !in trackedCodes) { - track(InternalSuperwallEvent.FreeTrialStart(paywallInfo, StoreProduct(RedemptionStoreProduct(product)))) - storage.write(TrackedWebTrialCodes, trackedCodes + result.code) + 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) diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt index d1bb51199..b914d1fca 100644 --- a/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt @@ -155,6 +155,33 @@ class WebRedemptionTrialTest { 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 { From 3d5db1435b8e6d873f262a0230a66016cc94d456 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 17:02:26 +0200 Subject: [PATCH 24/28] Version bump --- CHANGELOG.md | 3 ++- version.env | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a6066a3..46cd37f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. -## Unreleased +## 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. 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 From 24e602dd3dbe11e0f1bbe34c2dc81cab904e9929 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 11 Sep 2026 15:25:29 +0000 Subject: [PATCH 25/28] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 7392eda28..c0c8757f6 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.1% \ No newline at end of file +branches38.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 8e021ed1c..f0c8dbed0 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47% \ No newline at end of file +coverage47.1% \ No newline at end of file From 24a94eaa2331db4f426ecf5bfdb421f789c4b352 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 17:36:17 +0200 Subject: [PATCH 26/28] Update superwall/src/main/java/com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> --- .../com/superwall/sdk/paywall/view/webview/PaywallWebUI.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6c1889eb9..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,9 +14,9 @@ interface PaywallWebUI { fun enableBackgroundRendering() - fun onPause() + fun onPause() {} - fun onResume() + fun onResume() {} fun scrollBy( x: Int, From 3025a1ed6df4bf7913a103d68fb6159967ba43a7 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 17:39:24 +0200 Subject: [PATCH 27/28] Handle overflowing and offset trial end dates in RedemptionStoreProduct trialPeriodEndDate only caught DateTimeParseException, so an overflowing date made toEpochMilli() throw ArithmeticException out of the lazy on every read. It also used Instant.parse, which in threetenbp rejects offset timestamps that webTrialReminderDelay accepts. Parse with OffsetDateTime and catch DateTimeException/ArithmeticException to match the reminder. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XmJQyMUf6TdAoVa6jYMb12 --- .../sdk/web/RedemptionStoreProduct.kt | 28 +++++++++---------- .../sdk/web/RedemptionStoreProductTest.kt | 4 ++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt index 6fde5298b..cf5f5fa97 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt @@ -3,8 +3,9 @@ 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.Instant +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 @@ -39,20 +40,19 @@ internal class RedemptionStoreProduct( 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 { - Date(Instant.parse(product.trialPeriodEndDate).toEpochMilli()) - } catch (_: DateTimeParseException) { - try { - Date( - LocalDate - .parse(product.trialPeriodEndDate) - .atStartOfDay() - .toInstant(ZoneOffset.UTC) - .toEpochMilli(), - ) - } catch (_: DateTimeParseException) { - null - } + 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 diff --git a/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt index 1ea77b638..76ab64cbf 100644 --- a/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt @@ -155,7 +155,9 @@ class RedemptionStoreProductTest { 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) - for (value in listOf("", "not a date")) { + 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) From b3ae2b95f888c4c7e483784a6d78a4fbff669956 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 11 Sep 2026 17:45:37 +0200 Subject: [PATCH 28/28] Fix ambiguous super calls in SWWebView onPause/onResume PaywallWebUI now provides default onPause/onResume bodies, so SWWebView inherits concrete implementations from both WebView and PaywallWebUI. Qualify the super calls with WebView to resolve the ambiguity. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LbUwGJ5vnr82Mag98Y2J2f --- .../java/com/superwall/sdk/paywall/view/webview/SWWebView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6bf9c04a8..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 @@ -197,11 +197,11 @@ class SWWebView( override fun onPause() { hostPaused = true updateMediaPlayback() - super.onPause() + super.onPause() } override fun onResume() { - super.onResume() + super.onResume() hostPaused = false updateMediaPlayback() }