From 4e3f4561f1ba2631d4881ba0e0539c5efabfd2cd Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 17 Sep 2026 12:14:35 +0200 Subject: [PATCH 01/15] Ensure legacy notifications only schedule when trial starts --- CHANGELOG.md | 6 + .../transactions/TransactionManagerTest.kt | 2 +- .../sdk/dependencies/DependencyContainer.kt | 16 +-- .../store/transactions/TransactionManager.kt | 13 +- .../notifications/TrialReminderLogic.kt | 25 ++++ .../transactions/TransactionManagerTest.kt | 115 ++++++++++++++++- .../notifications/TrialReminderLogicTest.kt | 117 ++++++++++++++++++ 7 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogic.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogicTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 46cd37f6..e0c1147e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.8.4 + +## Fixes +- Fix trial reminder notifications being scheduled after a paywall purchase that did not start a free trial (for example a user who already used their trial and was charged immediately). Reminders and the `freeTrial_start` paywall message are now only sent when a trial actually started. + + ## 2.8.3 ## Fixes diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt index 909f0096..eebad9ea 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt @@ -208,7 +208,7 @@ class TransactionManagerTest { dismiss = { i, e -> dismiss(i, e) }, showAlert = { showAlert(it) }, updateState = { cacheKey, update -> updateState(cacheKey, update) }, - notifyOfTransactionComplete = { cacheKey, trialEndDate, id -> notifyOfTransactionComplete(cacheKey, trialEndDate) }, + notifyOfTransactionComplete = { cacheKey, trialEndDate, id, _ -> notifyOfTransactionComplete(cacheKey, trialEndDate) }, eventsQueue = eventsQueue, factory = trManagerFactory, ioScope = IOScope(backgroundScope.coroutineContext), 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 fe6b0d72..401f6d33 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -62,7 +62,6 @@ 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 import com.superwall.sdk.network.Api @@ -128,6 +127,7 @@ import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.store.testmode.TestMode import com.superwall.sdk.store.testmode.TestModeTransactionHandler import com.superwall.sdk.store.transactions.TransactionManager +import com.superwall.sdk.store.transactions.notifications.TrialReminderLogic import com.superwall.sdk.utilities.DateUtils import com.superwall.sdk.utilities.ErrorTracker import com.superwall.sdk.utilities.dateFormat @@ -642,7 +642,7 @@ class DependencyContainer( } paywallView.webView.messageHandler.handle(PaywallMessage.TransactionAbandon) }, - notifyOfTransactionComplete = { key, trialEndDate, id -> + notifyOfTransactionComplete = { key, trialEndDate, id, didStartFreeTrial -> val paywallView = resolvePaywallViewForKey( makeViewStore(), @@ -663,14 +663,14 @@ class DependencyContainer( // hasn't been updated to send the ScheduleNotification message dynamically. // If the paywall sends a ScheduleNotification message, it will cancel and // replace this notification. + // Nothing is scheduled (and no trial-started message is sent to the paywall) + // when the purchase did not start a free trial. val paywallInfo = paywallView.state.info val trialNotifications = - paywallInfo.localNotifications - .filter { - it.type == LocalNotificationType.TrialStarted - }.map { - it.copy("${paywallInfo.identifier}_${it.type.raw}") - } + TrialReminderLogic.fallbackTrialNotifications( + paywallInfo = paywallInfo, + didStartFreeTrial = didStartFreeTrial, + ) if (trialNotifications.isNotEmpty()) { diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index 5d95f9e2..bf50f8e9 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -90,7 +90,7 @@ class TransactionManager( private val notifyBackendOfReceipts: suspend () -> Unit = {}, private val refreshReceipt: () -> Unit, private val updateState: (cacheKey: String, update: PaywallViewState.Updates) -> Unit, - private val notifyOfTransactionComplete: suspend (paywallCacheKey: String, trialEndDate: Long?, productId: String) -> Unit, + private val notifyOfTransactionComplete: suspend (paywallCacheKey: String, trialEndDate: Long?, productId: String, didStartFreeTrial: Boolean) -> Unit, private val notifyOfTransactionAbandon: suspend (paywallCacheKey: String) -> Unit = {}, private val testMode: TestMode? = null, private val testModeTransactionHandler: TestModeTransactionHandler? = null, @@ -1178,7 +1178,9 @@ class TransactionManager( when (purchaseSource) { is PurchaseSource.Internal -> { - val trialEnd = product.trialPeriodEndDate?.time + // Only report a trial end when a trial actually started; product metadata alone + // can describe a trial the user was not eligible for. + val trialEnd = if (didStartFreeTrial) product.trialPeriodEndDate?.time else null val paywallInfo = purchaseSource.paywallInfo val trackedEvent = @@ -1204,7 +1206,12 @@ class TransactionManager( ) track(nonRecurringEvent) } else { - notifyOfTransactionComplete(purchaseSource.paywallInfo.cacheKey, trialEnd, product.fullIdentifier) + notifyOfTransactionComplete( + purchaseSource.paywallInfo.cacheKey, + trialEnd, + product.fullIdentifier, + didStartFreeTrial, + ) if (didStartFreeTrial) { val freeTrialEvent = InternalSuperwallEvent.FreeTrialStart(paywallInfo, product) diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogic.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogic.kt new file mode 100644 index 00000000..6575dff4 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogic.kt @@ -0,0 +1,25 @@ +package com.superwall.sdk.store.transactions.notifications + +import com.superwall.sdk.models.paywall.LocalNotification +import com.superwall.sdk.models.paywall.LocalNotificationType +import com.superwall.sdk.paywall.presentation.PaywallInfo + +internal object TrialReminderLogic { + /** + * Builds the fallback trial reminders to schedule after a paywall purchase completes. + * + * Reminders are only meaningful when a free trial actually started. A purchase that + * charges immediately (no trial offer, or the user already consumed their trial) must not + * schedule a "your free trial ends" notification, even if the paywall has trial reminders + * configured. + */ + fun fallbackTrialNotifications( + paywallInfo: PaywallInfo, + didStartFreeTrial: Boolean, + ): List { + if (!didStartFreeTrial) return emptyList() + return paywallInfo.localNotifications + .filter { it.type == LocalNotificationType.TrialStarted } + .map { it.copy(id = "${paywallInfo.identifier}_${it.type.raw}") } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt index c3a2888c..a6e9885a 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt @@ -44,10 +44,13 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.util.Date import java.util.concurrent.ConcurrentHashMap class TransactionManagerTest { @@ -78,6 +81,7 @@ class TransactionManagerTest { private val alertCalls = mutableListOf() private val stateUpdates = mutableListOf>() private val transactionCompleteCalls = mutableListOf>() + private val transactionCompleteTrialFlags = mutableListOf() private var subscriptionStatusValue: SubscriptionStatus = SubscriptionStatus.Active(setOf(Entitlement("test"))) private var webEntitlementsValue: Set = emptySet() private val entitlementsMap = mutableMapOf>() @@ -93,6 +97,7 @@ class TransactionManagerTest { alertCalls.clear() stateUpdates.clear() transactionCompleteCalls.clear() + transactionCompleteTrialFlags.clear() refreshReceiptCalled = false showRestoreDialogCalled = false webEntitlementsValue = emptySet() @@ -133,7 +138,10 @@ class TransactionManagerTest { showRestoreDialogForWeb = { showRestoreDialogCalled = true }, refreshReceipt = { refreshReceiptCalled = true }, updateState = { key, update -> stateUpdates.add(key to update) }, - notifyOfTransactionComplete = { cacheKey, trialEndDate, id -> transactionCompleteCalls.add(cacheKey to trialEndDate) }, + notifyOfTransactionComplete = { cacheKey, trialEndDate, id, didStartFreeTrial -> + transactionCompleteCalls.add(cacheKey to trialEndDate) + transactionCompleteTrialFlags.add(didStartFreeTrial) + }, ) } @@ -527,6 +535,111 @@ class TransactionManagerTest { // endregion + // region trial reminder regression (2.7.13: reminder scheduled without a trial) + + @Test + fun purchase_internalSubscriptionWithoutTrial_doesNotReportTrialStarted() = + runTest { + Given("a paywall purchase of a subscription whose selected offer has no free trial") { + // A user who already consumed their trial is charged immediately. The paywall may + // still have Trial Reminders configured, so the completion callback must say no + // trial started, otherwise a "your free trial ends" reminder gets scheduled. + val productId = "product_no_trial" + val rawProduct = + mockk(relaxed = true) { + every { fullIdentifier } returns productId + every { underlyingProductDetails } returns mockProductDetails(productId) + every { hasFreeTrial } returns false + every { selectedOffer } returns null + } + val product = + mockk(relaxed = true) { + every { fullIdentifier } returns productId + every { hasFreeTrial } returns false + every { rawStoreProduct } returns rawProduct + every { subscriptionPeriod } returns mockk(relaxed = true) + // Stale metadata can still carry a trial end date; it must not be used. + every { trialPeriodEndDate } returns Date(1_700_000_000_000L) + } + every { storeManager.getProductFromCache(productId) } returns product + coEvery { + storeManager.purchaseController.purchase(any(), any(), any(), any()) + } returns PurchaseResult.Purchased() + + When("the purchase completes") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + productId, + mockk(relaxed = true), + ), + ) + + Then("the transaction completes without a trial having started") { + assertTrue(result is PurchaseResult.Purchased) + assertEquals(listOf(false), transactionCompleteTrialFlags) + assertEquals(1, transactionCompleteCalls.size) + assertNull(transactionCompleteCalls.single().second) + And("a subscription start rather than a free trial start is tracked") { + assertTrue(trackedEvents.any { it is InternalSuperwallEvent.SubscriptionStart }) + assertFalse(trackedEvents.any { it is InternalSuperwallEvent.FreeTrialStart }) + } + } + } + } + } + + @Test + fun purchase_internalSubscriptionWithTrial_reportsTrialStartedAndEndDate() = + runTest { + Given("a paywall purchase of a subscription whose selected offer includes a free trial") { + val productId = "product_with_trial" + val trialEnd = Date(1_800_000_000_000L) + val rawProduct = + mockk(relaxed = true) { + every { fullIdentifier } returns productId + every { underlyingProductDetails } returns mockProductDetails(productId) + every { hasFreeTrial } returns true + every { selectedOffer } returns + RawStoreProduct.SelectedOfferDetails.Subscription(mockk(relaxed = true)) + } + val product = + mockk(relaxed = true) { + every { fullIdentifier } returns productId + every { hasFreeTrial } returns true + every { rawStoreProduct } returns rawProduct + every { subscriptionPeriod } returns mockk(relaxed = true) + every { trialPeriodEndDate } returns trialEnd + } + every { storeManager.getProductFromCache(productId) } returns product + coEvery { + storeManager.purchaseController.purchase(any(), any(), any(), any()) + } returns PurchaseResult.Purchased() + + When("the purchase completes") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + productId, + mockk(relaxed = true), + ), + ) + + Then("the completion callback reports the trial and its end date") { + assertTrue(result is PurchaseResult.Purchased) + assertEquals(listOf(true), transactionCompleteTrialFlags) + assertEquals(trialEnd.time, transactionCompleteCalls.single().second) + And("a free trial start is tracked") { + assertTrue(trackedEvents.any { it is InternalSuperwallEvent.FreeTrialStart }) + assertFalse(trackedEvents.any { it is InternalSuperwallEvent.SubscriptionStart }) + } + } + } + } + } + + // endregion + private fun mockStoreProduct( productId: String, rawProduct: RawStoreProduct? = null, diff --git a/superwall/src/test/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogicTest.kt new file mode 100644 index 00000000..3eb6d5c8 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/store/transactions/notifications/TrialReminderLogicTest.kt @@ -0,0 +1,117 @@ +package com.superwall.sdk.store.transactions.notifications + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.models.paywall.LocalNotification +import com.superwall.sdk.models.paywall.LocalNotificationType +import com.superwall.sdk.paywall.presentation.PaywallInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression tests for trial reminders being scheduled after purchases that did not start a + * free trial (reported against 2.7.13: "Your free trial ends…" shown after buying a + * subscription with no trial on a paywall with Trial Reminders enabled). + */ +class TrialReminderLogicTest { + private val trialReminder = + LocalNotification( + id = "dashboard-id", + type = LocalNotificationType.TrialStarted, + title = "Your free trial ends soon", + body = "Your trial ends in 2 days.", + delay = 5L * 24 * 60 * 60 * 1000, + ) + + private fun paywallInfo(notifications: List) = + PaywallInfo.empty().copy( + identifier = "paywall_abc", + localNotifications = notifications, + ) + + @Test + fun noTrialStarted_schedulesNothingEvenWhenRemindersAreConfigured() { + Given("a paywall with a trial reminder configured and a purchase that charged immediately") { + val info = paywallInfo(listOf(trialReminder)) + + When("fallback reminders are computed") { + val result = + TrialReminderLogic.fallbackTrialNotifications( + paywallInfo = info, + didStartFreeTrial = false, + ) + + Then("no reminder is scheduled") { + assertTrue(result.isEmpty()) + } + } + } + } + + @Test + fun trialStarted_schedulesConfiguredRemindersWithPaywallScopedIds() { + Given("a paywall with a trial reminder configured and a purchase that started a trial") { + val info = paywallInfo(listOf(trialReminder)) + + When("fallback reminders are computed") { + val result = + TrialReminderLogic.fallbackTrialNotifications( + paywallInfo = info, + didStartFreeTrial = true, + ) + + Then("the reminder is scheduled under a paywall-scoped id") { + assertEquals(1, result.size) + val scheduled = result.single() + assertEquals("paywall_abc_TRIAL_STARTED", scheduled.id) + assertEquals(trialReminder.title, scheduled.title) + assertEquals(trialReminder.body, scheduled.body) + assertEquals(trialReminder.delay, scheduled.delay) + } + } + } + } + + @Test + fun trialStarted_ignoresNonTrialNotificationTypes() { + Given("a paywall whose only notifications are of an unsupported type") { + val info = + paywallInfo( + listOf(trialReminder.copy(type = LocalNotificationType.Unsupported)), + ) + + When("fallback reminders are computed for a started trial") { + val result = + TrialReminderLogic.fallbackTrialNotifications( + paywallInfo = info, + didStartFreeTrial = true, + ) + + Then("nothing is scheduled") { + assertTrue(result.isEmpty()) + } + } + } + } + + @Test + fun trialStarted_withoutConfiguredReminders_schedulesNothing() { + Given("a paywall without trial reminders") { + val info = paywallInfo(emptyList()) + + When("fallback reminders are computed for a started trial") { + val result = + TrialReminderLogic.fallbackTrialNotifications( + paywallInfo = info, + didStartFreeTrial = true, + ) + + Then("nothing is scheduled") { + assertTrue(result.isEmpty()) + } + } + } + } +} From 41541c13ef274c2dc055a76ce61113a6deec7b4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 17 Sep 2026 10:35:18 +0000 Subject: [PATCH 02/15] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index c0c8757f..96d21397 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.2% \ No newline at end of file +branches38.3% \ No newline at end of file From f1ffb7d8202b9e58bb13f8c315f29df26acd0a2a Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 17 Sep 2026 13:00:55 +0200 Subject: [PATCH 03/15] Ensure paywall sharing among experiments doesnt reuse their id --- .../get_paywall/InternalGetPaywall.kt | 4 + .../internal/GetPaywallComponents.kt | 2 + .../internal/InternalPresentation.kt | 1 + .../internal/operators/PresentPaywall.kt | 4 + .../superwall/sdk/paywall/view/PaywallView.kt | 6 +- .../sdk/paywall/view/PaywallViewState.kt | 23 +- .../PaywallManagerExperimentIsolationTest.kt | 286 ++++++++++++++++++ .../sdk/paywall/view/PaywallViewStateTest.kt | 55 ++++ 8 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerExperimentIsolationTest.kt diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt index c0982770..5529930c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt @@ -3,6 +3,7 @@ package com.superwall.sdk.paywall.presentation.get_paywall import android.app.Activity import com.superwall.sdk.Superwall import com.superwall.sdk.misc.Either +import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.getPaywallComponents import com.superwall.sdk.paywall.presentation.internal.operators.logErrors @@ -19,6 +20,8 @@ data class PaywallComponents( val presenter: Activity?, val rulesOutcome: RuleEvaluationOutcome, val debugInfo: Map, + /** The experiment the request resolved to. Bound to the view together with the request. */ + val experiment: Experiment? = null, ) /** @@ -39,6 +42,7 @@ internal suspend fun Superwall.getPaywall( request = request, paywallStatePublisher = publisher, unsavedOccurrence = it.rulesOutcome.unsavedOccurrence, + experiment = it.experiment, ) Either.Success(it.view) }, onFailure = { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt index b07b1475..341b9c5e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt @@ -4,6 +4,7 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.toResult import com.superwall.sdk.models.assignment.ConfirmedAssignment +import com.superwall.sdk.models.triggers.InternalTriggerResult import com.superwall.sdk.paywall.presentation.get_paywall.PaywallComponents import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.utilities.withErrorTracking @@ -59,6 +60,7 @@ internal suspend fun runGetPaywallComponents( presenter = presenter, rulesOutcome = outcome, debugInfo = debugInfo, + experiment = (outcome.triggerResult as? InternalTriggerResult.Paywall)?.experiment, ) }.toResult() diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt index 0cf3993c..4e770716 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/InternalPresentation.kt @@ -48,6 +48,7 @@ internal suspend fun Superwall.internallyPresent( debugInfo = paywallComponents.debugInfo, request = request, paywallStatePublisher = publisher, + experiment = paywallComponents.experiment, ) } catch (e: Throwable) { logErrors(request, e) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt index 618d7e4c..2648e28c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt @@ -7,6 +7,7 @@ import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.TriggerRuleOccurrence import com.superwall.sdk.paywall.presentation.internal.InternalPresentationLogic import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus @@ -31,6 +32,7 @@ import kotlinx.coroutines.withContext * @param debugInfo Information to help with debugging. * @param request The request to present the paywall. * @param paywallStatePublisher A `MutableStateFlow` that gets sent `PaywallState` objects. + * @param experiment The experiment the request resolved to, bound to the view with the request. * * @return A publisher that contains info for the next pipeline operator. */ @@ -41,6 +43,7 @@ suspend fun Superwall.presentPaywallView( debugInfo: Map, request: PresentationRequest, paywallStatePublisher: MutableSharedFlow, + experiment: Experiment? = null, ) { val trackedEvent = InternalSuperwallEvent.PresentationRequest( @@ -64,6 +67,7 @@ suspend fun Superwall.presentPaywallView( unsavedOccurrence = unsavedOccurrence, presentationStyleOverride = request.paywallOverrides?.presentationStyle, paywallStatePublisher = paywallStatePublisher, + experiment = experiment, ) { isPresented -> if (isPresented) { val state = PaywallState.Presented(paywallView.info) 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 d33dddb4..f6cbd3be 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 @@ -46,6 +46,7 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope import com.superwall.sdk.misc.toResult import com.superwall.sdk.models.paywall.PaywallPresentationStyle +import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.TriggerRuleOccurrence import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.paywall.manager.PaywallViewCache @@ -261,12 +262,14 @@ class PaywallView( request: PresentationRequest, paywallStatePublisher: MutableSharedFlow, unsavedOccurrence: TriggerRuleOccurrence?, + experiment: Experiment? = null, ) { controller.updateState( SetRequest( request, paywallStatePublisher, unsavedOccurrence, + experiment, ), ) } @@ -303,6 +306,7 @@ class PaywallView( unsavedOccurrence: TriggerRuleOccurrence?, presentationStyleOverride: PaywallPresentationStyle?, paywallStatePublisher: MutableSharedFlow, + experiment: Experiment? = null, completion: (Boolean) -> Unit, ) { webView.attach(this) @@ -351,7 +355,7 @@ class PaywallView( cache?.acquireShimmerView()?.let { setupShimmer(it) } - set(request, paywallStatePublisher, unsavedOccurrence) + set(request, paywallStatePublisher, unsavedOccurrence, experiment) controller.updateState( SetPresentationConfig( presentationStyleOverride, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt index 7f6d0031..b038f3cd 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt @@ -3,6 +3,7 @@ package com.superwall.sdk.paywall.view import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallPresentationStyle +import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.TriggerRuleOccurrence import com.superwall.sdk.paywall.manager.PaywallCacheLogic import com.superwall.sdk.paywall.presentation.PaywallCloseReason @@ -92,9 +93,14 @@ data class PaywallViewState( swProductVariablesTemplate = from.swProductVariablesTemplate, isFreeTrialAvailable = from.isFreeTrialAvailable, productsLoadingInfo = from.productsLoadingInfo, - presentationSourceType = from.presentationSourceType, - experiment = from.experiment, ) + // Note: `experiment` and `presentationSourceType` are deliberately NOT merged here. + // The view is cached per paywall identifier and shared by every campaign that uses + // the paywall, and this merge runs before the caller binds its request. A second + // request for the same paywall (e.g. getPaywall/getPresentationResult while an + // implicit presentation is in flight) would otherwise overwrite the experiment the + // first presentation reports. They are bound atomically with the request in + // [SetRequest] instead. // Update productItems via setter to also refresh related fields. merged.productItems = from.productItems state.copy(paywall = merged) @@ -106,12 +112,25 @@ data class PaywallViewState( state.copy(customerInfo = customerInfo) }) + /** + * Binds a presentation request to the view. The experiment and presentation source are + * bound together with the request so that [PaywallViewState.info] never combines one + * request's placement with another request's experiment. + * + * @param experiment The experiment this request resolved to. `null` keeps the current one. + */ class SetRequest( val req: PresentationRequest, val publisher: MutableSharedFlow?, val occurrence: TriggerRuleOccurrence?, + val experiment: Experiment? = null, ) : Updates({ state -> state.copy( + paywall = + state.paywall.copy( + experiment = experiment ?: state.paywall.experiment, + presentationSourceType = req.presentationSourceType, + ), request = req, paywallStatePublisher = publisher, unsavedOccurrence = occurrence, diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerExperimentIsolationTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerExperimentIsolationTest.kt new file mode 100644 index 00000000..f690dec4 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerExperimentIsolationTest.kt @@ -0,0 +1,286 @@ +package com.superwall.sdk.paywall.manager + +import android.view.View +import android.view.ViewGroup +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.analytics.internal.TrackingResult +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.MainScope +import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.paywall.PaywallURL +import com.superwall.sdk.models.triggers.Experiment +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.network.device.DeviceInfo +import com.superwall.sdk.paywall.presentation.internal.PresentationRequest +import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType +import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo +import com.superwall.sdk.paywall.presentation.internal.state.PaywallState +import com.superwall.sdk.paywall.request.PaywallRequest +import com.superwall.sdk.paywall.request.PaywallRequestManager +import com.superwall.sdk.paywall.request.ResponseIdentifiers +import com.superwall.sdk.paywall.view.PaywallView +import com.superwall.sdk.paywall.view.PaywallViewState +import com.superwall.sdk.paywall.view.delegate.PaywallViewDelegateAdapter +import com.superwall.sdk.paywall.view.webview.PaywallUIDelegate +import com.superwall.sdk.paywall.view.webview.PaywallWebUI +import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler +import com.superwall.sdk.storage.LocalStorage +import com.superwall.sdk.web.WebPaywallRedeemer +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.Date + +/** + * Regression test: two campaigns that share one paywall must not report each + * other's experiment. The cached [PaywallView] is shared per paywall identifier, + * so an in-flight request for campaign A must not rewrite the experiment that + * campaign B's presentation reports in `paywall_open` and transaction events. + * + * Replays the interleaving from the iOS report with real coroutines: + * B fetches the view, A fetches the same view, A binds its request, B binds its request. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class PaywallManagerExperimentIsolationTest { + private val context get() = RuntimeEnvironment.getApplication() + + private lateinit var paywallRequestManager: PaywallRequestManager + private lateinit var paywallManager: PaywallManager + private lateinit var viewFactory: PaywallView.Factory + private var cachedView: PaywallView? = null + + private val paywallId = "shared_paywall" + private val experimentA = experiment("181395") + private val experimentB = experiment("180872") + + private fun experiment(id: String) = + Experiment( + id = id, + groupId = "group", + variant = Experiment.Variant(id = "v_$id", type = Experiment.Variant.VariantType.TREATMENT, paywallId = paywallId), + ) + + @Before + fun setUp() { + viewFactory = mockk(relaxed = true) + every { viewFactory.makeSuperwallOptions() } returns SuperwallOptions() + every { viewFactory.updatePaywallInfo(any()) } just Runs + every { viewFactory.getCurrentUserAttributes() } returns emptyMap() + coEvery { viewFactory.track(any()) } returns Result.success(mockk(relaxed = true)) + + val cache = + mockk(relaxed = true) { + every { getPaywallView(any()) } answers { cachedView } + every { save(any(), any()) } answers { cachedView = firstArg() } + } + val deviceInfo = mockk { every { locale } returns "en_US" } + val managerFactory = + mockk { + every { makeCache() } returns cache + every { makeDeviceInfo() } returns deviceInfo + every { mainScope() } returns MainScope(Dispatchers.Unconfined) + coEvery { makePaywallView(any(), any(), any()) } coAnswers { makeRealView(firstArg()) } + } + paywallRequestManager = mockk() + paywallManager = PaywallManager(managerFactory, paywallRequestManager) + } + + private fun makeRealView(paywall: Paywall): PaywallView { + val controller = PaywallView.PaywallController(PaywallViewState(paywall = paywall, locale = "en_US")) + val webUI = FakePaywallWebUI(mockk(relaxed = true)) + return PaywallView( + context = context, + eventCallback = null, + callback = null, + deviceHelper = mockk(relaxed = true), + factory = viewFactory, + storage = mockk(relaxed = true), + webView = webUI, + cache = null, + controller = controller, + sendMessages = mockk(relaxed = true), + redeemer = mockk(relaxed = true), + ) + } + + private fun paywallRequest( + experiment: Experiment, + eventName: String, + source: String, + ) = PaywallRequest( + eventData = EventData(name = eventName, parameters = emptyMap(), createdAt = Date()), + responseIdentifiers = ResponseIdentifiers(paywallId = paywallId, experiment = experiment), + overrides = PaywallRequest.Overrides(products = null, isFreeTrial = null), + isDebuggerLaunched = false, + presentationSourceType = source, + retryCount = 0, + ) + + private fun presentationRequest( + info: PresentationInfo, + type: PresentationRequestType, + ) = PresentationRequest( + presentationInfo = info, + presenter = null, + paywallOverrides = null, + flags = + PresentationRequest.Flags( + isDebuggerLaunched = false, + entitlements = MutableStateFlow(null), + isPaywallPresented = false, + type = type, + ), + ) + + @Test + fun `interleaved requests for a shared paywall each report their own experiment`() = + runTest { + Given("a shared paywall, an implicit session_start request for experiment B and a getPaywall request for experiment A") { + val base = Paywall.stub().copy(identifier = paywallId) + // The request manager returns the paywall stamped with each request's experiment, + // as PaywallRequestManager.updatePaywall does, after a controllable delay. + val gates = mapOf(experimentA.id to CompletableDeferred(), experimentB.id to CompletableDeferred()) + coEvery { paywallRequestManager.getPaywall(any(), any()) } coAnswers { + val req = firstArg() + val exp = req.responseIdentifiers.experiment!! + gates.getValue(exp.id).await() + Either.Success(base.copy(experiment = exp, presentationSourceType = req.presentationSourceType)) + } + + val eventB = EventData(name = "session_start", parameters = emptyMap(), createdAt = Date()) + val eventA = EventData(name = "campaign_trigger", parameters = emptyMap(), createdAt = Date()) + val requestB = presentationRequest(PresentationInfo.ImplicitTrigger(eventB), PresentationRequestType.Presentation) + val requestA = + presentationRequest( + PresentationInfo.ExplicitTrigger(eventA), + PresentationRequestType.GetPaywall(mockk(relaxed = true)), + ) + val bindA = CompletableDeferred() + val bindB = CompletableDeferred() + val publisher = MutableSharedFlow() + var viewA: PaywallView? = null + var viewB: PaywallView? = null + + When("B fetches the view, A fetches the same view, A binds, then B binds") { + val pipelineB = + launch { + val view = + ( + paywallManager.getPaywallView( + paywallRequest(experimentB, "session_start", "implicit"), + isForPresentation = true, + isPreloading = false, + delegate = null, + ) as Either.Success + ).value + viewB = view + bindB.await() // the async gap before presentPaywallView -> present -> set + view.set(requestB, publisher, null, experimentB) + } + val pipelineA = + launch { + val view = + ( + paywallManager.getPaywallView( + paywallRequest(experimentA, "campaign_trigger", "getPaywall"), + isForPresentation = true, + isPreloading = false, + delegate = mockk(relaxed = true), + ) as Either.Success + ).value + viewA = view + bindA.await() // the async gap before InternalGetPaywall binds the request + view.set(requestA, publisher, null, experimentA) + } + + gates.getValue(experimentB.id).complete(Unit) + testScheduler.advanceUntilIdle() + gates.getValue(experimentA.id).complete(Unit) + testScheduler.advanceUntilIdle() + bindA.complete(Unit) + testScheduler.advanceUntilIdle() + + Then("both pipelines got the same cached view and A's binding reports A") { + assertSame(viewA, viewB) + val infoA = viewA!!.info + assertEquals("campaign_trigger", infoA.presentedByEventWithName) + assertEquals(experimentA.id, infoA.experiment?.id) + assertEquals("getPaywall", infoA.presentationSourceType) + } + + bindB.complete(Unit) + testScheduler.advanceUntilIdle() + pipelineA.join() + pipelineB.join() + + Then("B's binding reports B's experiment and source, not A's") { + val infoB = viewB!!.info + assertEquals("session_start", infoB.presentedByEventWithName) + assertEquals(experimentB.id, infoB.experiment?.id) + assertEquals("implicit", infoB.presentationSourceType) + } + } + } + } + + private inner class FakePaywallWebUI( + override val messageHandler: PaywallMessageHandler, + ) : PaywallWebUI { + override var delegate: PaywallUIDelegate? = null + override var onScrollChangeListener: PaywallWebUI.OnScrollChangeListener? = null + private val view = View(context) + + override fun onView(perform: View.() -> Unit) = perform(view) + + override fun enableBackgroundRendering() = Unit + + override fun scrollBy( + x: Int, + y: Int, + ) = Unit + + override fun scrollTo( + x: Int, + y: Int, + ) = Unit + + override fun setup( + url: PaywallURL, + onRenderCrashed: (Boolean, Int) -> Unit, + ) = Unit + + override fun evaluate( + code: String, + resultCallback: ((String?) -> Unit)?, + ) { + resultCallback?.invoke(null) + } + + override fun destroyView() = Unit + + override fun detach(fromView: ViewGroup) = Unit + + override fun attach(toView: ViewGroup) = Unit + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt index 57aaf8ea..e8fcdd42 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt @@ -9,6 +9,7 @@ import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallPresentationStyle import com.superwall.sdk.models.product.CrossplatformProduct import com.superwall.sdk.models.product.Offer +import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.TriggerRuleOccurrence import com.superwall.sdk.paywall.presentation.PaywallCloseReason import com.superwall.sdk.paywall.presentation.internal.PresentationRequest @@ -587,4 +588,58 @@ class PaywallViewStateTest { } } } + + private fun experiment(id: String) = + Experiment( + id = id, + groupId = "group", + variant = Experiment.Variant(id = "v_$id", type = Experiment.Variant.VariantType.TREATMENT, paywallId = "pw"), + ) + + @Test + fun mergePaywall_doesNotOverwriteExperimentOrPresentationSource() { + Given("a state whose paywall is bound to experiment A") { + val state = + makeState(Paywall.stub().copy(experiment = experiment("A"), presentationSourceType = "implicit")) + val from = Paywall.stub().copy(experiment = experiment("B"), presentationSourceType = "getPaywall") + + When("a paywall resolved for experiment B is merged") { + val newState = PaywallViewState.Updates.MergePaywall(from).transform(state) + + Then("the experiment and source stay bound to A") { + assertEquals("A", newState.paywall.experiment?.id) + assertEquals("implicit", newState.paywall.presentationSourceType) + } + } + } + } + + @Test + fun setRequest_bindsExperimentAndSourceTogetherWithRequest() { + Given("a state whose paywall carries a stale experiment") { + val state = makeState(Paywall.stub().copy(experiment = experiment("stale"), presentationSourceType = "getPaywall")) + val request = makeRequest() // explicit trigger, Presentation type -> "register" + + When("a request is bound with its experiment") { + val newState = + PaywallViewState.Updates + .SetRequest(request, null, null, experiment("bound")) + .transform(state) + + Then("info reports the bound experiment, the request's source and the request's placement") { + assertEquals("bound", newState.info.experiment?.id) + assertEquals("register", newState.info.presentationSourceType) + assertEquals("evt", newState.info.presentedByEventWithName) + } + } + + When("a request is bound without an experiment") { + val newState = PaywallViewState.Updates.SetRequest(request, null, null).transform(state) + + Then("the current experiment is kept") { + assertEquals("stale", newState.info.experiment?.id) + } + } + } + } } From 6969fda23951abe9319354a33b2491db005e0e85 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 17 Sep 2026 14:33:49 +0200 Subject: [PATCH 04/15] Bind presentationId with the request and make experiment required on the seam Address review on #468: - SetRequest now also owns presentationId: the first binding of a view keeps the id minted at fetch time (its load events already carry it), every re-binding of a cached view mints a new one. MergePaywall leaves it alone, like experiment and presentationSourceType. - `experiment` is a required parameter on SetRequest, PaywallView.set/present, presentPaywallView(Sync) and PaywallComponents, and the `?: state.paywall.experiment` fallback is gone, so a caller can no longer silently inherit whatever the shared cached view was last bound to. Co-Authored-By: Claude Fable 5.1 --- .../paywall/view/PaywallViewDismissTest.kt | 6 +-- .../get_paywall/InternalGetPaywall.kt | 2 +- .../internal/operators/PresentPaywall.kt | 4 +- .../superwall/sdk/paywall/view/PaywallView.kt | 4 +- .../sdk/paywall/view/PaywallViewState.kt | 35 ++++++++++------ .../sdk/paywall/view/PaywallViewStateTest.kt | 40 ++++++++++++++++--- .../sdk/paywall/view/PaywallViewTest.kt | 3 ++ 7 files changed, 69 insertions(+), 25 deletions(-) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt index 63f6309e..c0bc4675 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/PaywallViewDismissTest.kt @@ -113,7 +113,7 @@ class PaywallViewDismissTest { val request = makeRequest() Given("a paywall view configured with a dismissal callback") { withContext(Dispatchers.Main) { - view.set(request, publisher, null) + view.set(request, publisher, null, null) view.onViewCreated() } @@ -182,7 +182,7 @@ class PaywallViewDismissTest { val request = makeRequest() Given("an embedded paywall showing the purchase spinner") { withContext(Dispatchers.Main) { - view.set(request, publisher, null) + view.set(request, publisher, null, null) view.onViewCreated() // Buy tap sets this in production (Superwall.kt InitiatePurchase). view.updateState( @@ -232,7 +232,7 @@ class PaywallViewDismissTest { val request = makeRequest() Given("a paywall view configured to continue to the next paywall") { withContext(Dispatchers.Main) { - view.set(request, publisher, null) + view.set(request, publisher, null, null) view.onViewCreated() } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt index 5529930c..d6a6e803 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt @@ -21,7 +21,7 @@ data class PaywallComponents( val rulesOutcome: RuleEvaluationOutcome, val debugInfo: Map, /** The experiment the request resolved to. Bound to the view together with the request. */ - val experiment: Experiment? = null, + val experiment: Experiment?, ) /** diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt index 2648e28c..1d9d3213 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/PresentPaywall.kt @@ -43,7 +43,7 @@ suspend fun Superwall.presentPaywallView( debugInfo: Map, request: PresentationRequest, paywallStatePublisher: MutableSharedFlow, - experiment: Experiment? = null, + experiment: Experiment?, ) { val trackedEvent = InternalSuperwallEvent.PresentationRequest( @@ -116,6 +116,7 @@ fun Superwall.presentPaywallViewSync( unsavedOccurrence: TriggerRuleOccurrence?, debugInfo: Map, request: PresentationRequest, + experiment: Experiment?, onStateChanged: (PaywallState) -> Unit, ) { mainScope.launch { @@ -132,6 +133,7 @@ fun Superwall.presentPaywallViewSync( debugInfo = debugInfo, request = request, paywallStatePublisher = publisher, + experiment = experiment, ) } } 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 f6cbd3be..27312c63 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 @@ -262,7 +262,7 @@ class PaywallView( request: PresentationRequest, paywallStatePublisher: MutableSharedFlow, unsavedOccurrence: TriggerRuleOccurrence?, - experiment: Experiment? = null, + experiment: Experiment?, ) { controller.updateState( SetRequest( @@ -306,7 +306,7 @@ class PaywallView( unsavedOccurrence: TriggerRuleOccurrence?, presentationStyleOverride: PaywallPresentationStyle?, paywallStatePublisher: MutableSharedFlow, - experiment: Experiment? = null, + experiment: Experiment?, completion: (Boolean) -> Unit, ) { webView.attach(this) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt index b038f3cd..1455b13f 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt @@ -15,6 +15,7 @@ import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState import com.superwall.sdk.paywall.view.survey.SurveyPresentationResult import kotlinx.coroutines.flow.MutableSharedFlow import java.util.Date +import java.util.UUID data class PaywallViewState( val paywall: Paywall, @@ -94,12 +95,12 @@ data class PaywallViewState( isFreeTrialAvailable = from.isFreeTrialAvailable, productsLoadingInfo = from.productsLoadingInfo, ) - // Note: `experiment` and `presentationSourceType` are deliberately NOT merged here. - // The view is cached per paywall identifier and shared by every campaign that uses - // the paywall, and this merge runs before the caller binds its request. A second - // request for the same paywall (e.g. getPaywall/getPresentationResult while an - // implicit presentation is in flight) would otherwise overwrite the experiment the - // first presentation reports. They are bound atomically with the request in + // Note: `experiment`, `presentationSourceType` and `presentationId` are deliberately + // NOT merged here. The view is cached per paywall identifier and shared by every + // campaign that uses the paywall, and this merge runs before the caller binds its + // request. A second request for the same paywall (e.g. getPaywall/getPresentationResult + // while an implicit presentation is in flight) would otherwise overwrite the metadata + // the first presentation reports. They are bound atomically with the request in // [SetRequest] instead. // Update productItems via setter to also refresh related fields. merged.productItems = from.productItems @@ -113,23 +114,33 @@ data class PaywallViewState( }) /** - * Binds a presentation request to the view. The experiment and presentation source are - * bound together with the request so that [PaywallViewState.info] never combines one - * request's placement with another request's experiment. + * Binds a presentation request to the view. The per-presentation metadata - experiment, + * presentation source and presentation id - is bound in the same transition as the + * request so that [PaywallViewState.info] never combines one request's placement with + * another request's experiment or presentation id. This is the only place that metadata + * is written; [MergePaywall] deliberately leaves it alone. * - * @param experiment The experiment this request resolved to. `null` keeps the current one. + * @param experiment The experiment this request resolved to. Required so a caller cannot + * silently inherit whatever the shared cached view was last bound to. */ class SetRequest( val req: PresentationRequest, val publisher: MutableSharedFlow?, val occurrence: TriggerRuleOccurrence?, - val experiment: Experiment? = null, + val experiment: Experiment?, ) : Updates({ state -> + // A fresh view is created with the presentation id its load events already carry, + // so the first binding keeps it. Every re-binding of a cached view is a new + // presentation and mints its own id. + val presentationId = + state.paywall.presentationId.takeIf { state.request == null } + ?: UUID.randomUUID().toString() state.copy( paywall = state.paywall.copy( - experiment = experiment ?: state.paywall.experiment, + experiment = experiment, presentationSourceType = req.presentationSourceType, + presentationId = presentationId, ), request = req, paywallStatePublisher = publisher, diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt index e8fcdd42..66bf2a2d 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt @@ -471,7 +471,7 @@ class PaywallViewStateTest { When("a new request is set for the second presentation") { val req = makeRequest() val publisher = MutableSharedFlow() - val afterSetRequest = PaywallViewState.Updates.SetRequest(req, publisher, null).transform(afterDismiss) + val afterSetRequest = PaywallViewState.Updates.SetRequest(req, publisher, null, null).transform(afterDismiss) Then("presentation flags allow presentationWillBegin to run") { assertEquals(true, afterSetRequest.presentationWillPrepare) @@ -562,7 +562,7 @@ class PaywallViewStateTest { val occurrence = TriggerRuleOccurrence.stub() When("SetRequest is applied") { - val newState = PaywallViewState.Updates.SetRequest(req, publisher, occurrence).transform(state) + val newState = PaywallViewState.Updates.SetRequest(req, publisher, occurrence, null).transform(state) Then("all fields are set and same instance preserved") { org.junit.Assert.assertSame(req, newState.request) @@ -633,11 +633,39 @@ class PaywallViewStateTest { } } - When("a request is bound without an experiment") { - val newState = PaywallViewState.Updates.SetRequest(request, null, null).transform(state) + When("a request is bound with a null experiment") { + val newState = PaywallViewState.Updates.SetRequest(request, null, null, null).transform(state) - Then("the current experiment is kept") { - assertEquals("stale", newState.info.experiment?.id) + Then("the stale experiment is not inherited") { + assertEquals(null, newState.info.experiment) + } + } + } + } + + @Test + fun setRequest_mintsPresentationIdPerPresentationOfACachedView() { + Given("a freshly created view whose paywall carries the presentation id minted at fetch time") { + val state = makeState(Paywall.stub().copy(presentationId = "created")) + val request = makeRequest() + + When("its first request is bound") { + val first = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(state) + + Then("the presentation id its load events already carry is kept") { + assertEquals("created", first.info.presentationId) + } + + When("a paywall is merged and a second request is bound to the cached view") { + val merged = PaywallViewState.Updates.MergePaywall(Paywall.stub().copy(presentationId = "other")).transform(first) + val second = PaywallViewState.Updates.SetRequest(request, null, null, experiment("B")).transform(merged) + + Then("the merge leaves the id alone and the re-binding mints a new one") { + assertEquals("created", merged.info.presentationId) + assert(second.info.presentationId != "created") + assert(second.info.presentationId != "other") + assert(!second.info.presentationId.isNullOrBlank()) + } } } } 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 125f239c..d1e018ba 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 @@ -746,6 +746,7 @@ class PaywallViewTest { req = mockk(relaxed = true), publisher = statePublisher, occurrence = null, + experiment = null, ), ) @@ -843,6 +844,7 @@ class PaywallViewTest { req = mockk(relaxed = true), publisher = statePublisher, occurrence = null, + experiment = null, ), ) view.controller.updateState(PaywallViewState.Updates.SetPresentedAndFinished) @@ -1035,6 +1037,7 @@ class PaywallViewTest { req = mockk(relaxed = true), publisher = statePublisher, occurrence = null, + experiment = null, ), ) view.controller.updateState(PaywallViewState.Updates.SetPresentedAndFinished) From 649af4f5e5f405472fbc3137ffc87f93200c528d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 17 Sep 2026 12:52:28 +0000 Subject: [PATCH 05/15] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index c0c8757f..96d21397 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.2% \ No newline at end of file +branches38.3% \ No newline at end of file From 0ce25ce443e0b80aeb78c4a84f17a54f88d03430 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 18 Sep 2026 13:32:45 +0200 Subject: [PATCH 06/15] Send freeTrial_start to the paywall for every started trial Decouple the TrialStarted webview message from the fallback reminder block so paywalls without trial reminders configured still receive it. Reminder scheduling stays gated on a started trial plus a configured TRIAL_STARTED notification. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../sdk/dependencies/DependencyContainer.kt | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c1147e..7404afad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 2.8.4 ## Fixes -- Fix trial reminder notifications being scheduled after a paywall purchase that did not start a free trial (for example a user who already used their trial and was charged immediately). Reminders and the `freeTrial_start` paywall message are now only sent when a trial actually started. +- Fix trial reminder notifications being scheduled after a paywall purchase that did not start a free trial (for example a user who already used their trial and was charged immediately). Reminders are now only scheduled when a trial actually started, and the paywall receives the `freeTrial_start` message for every started trial, even when it has no trial reminders configured. ## 2.8.3 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 401f6d33..da7d94c7 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -663,8 +663,7 @@ class DependencyContainer( // hasn't been updated to send the ScheduleNotification message dynamically. // If the paywall sends a ScheduleNotification message, it will cancel and // replace this notification. - // Nothing is scheduled (and no trial-started message is sent to the paywall) - // when the purchase did not start a free trial. + // Nothing is scheduled when the purchase did not start a free trial. val paywallInfo = paywallView.state.info val trialNotifications = TrialReminderLogic.fallbackTrialNotifications( @@ -694,12 +693,18 @@ class DependencyContainer( "No paywall activity available to schedule fallback notifications", ) } + } + + // Tell the paywall a trial started whenever one did, independent of whether + // it has trial reminders configured. This only forwards into the webview, so + // it does not double-track the freeTrial_start placement. + if (didStartFreeTrial) { // Await message delivery to ensure webview has time to process before dismiss paywallView.webView.messageHandler.handle( PaywallMessage.TrialStarted( trialEndDate, - id - ) + id, + ), ) } }, From 265ec9a592a7e74445287e4f8b7529db6bace7d5 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 21 Sep 2026 19:49:31 +0200 Subject: [PATCH 07/15] Version bump --- CHANGELOG.md | 1 + version.env | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7404afad..3d4d82e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 2.8.4 ## Fixes +- Fix bug where experiment ID would be stale among paywalls sharing experiments - Fix trial reminder notifications being scheduled after a paywall purchase that did not start a free trial (for example a user who already used their trial and was charged immediately). Reminders are now only scheduled when a trial actually started, and the paywall receives the `freeTrial_start` message for every started trial, even when it has no trial reminders configured. diff --git a/version.env b/version.env index 264f0073..19601be3 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.8.3 +SUPERWALL_VERSION=2.8.4 From d382f83a4028a7f2233e94edc88af21e4193aabd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Sep 2026 09:07:37 +0000 Subject: [PATCH 08/15] 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 96d21397..08ef66c7 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.3% \ No newline at end of file +branches38.4% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index f0c8dbed..1ae93b72 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.1% \ No newline at end of file +coverage47.2% \ No newline at end of file From 77c13c4f87939ac541945b11421e1d836ee06a7d Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 22 Sep 2026 11:56:36 +0200 Subject: [PATCH 09/15] Keep presentationId stable while a paywall is presented SetRequest minted a new presentationId on every re-binding of a cached view, including one that is currently on screen. The embedded getPaywall() path has no already-presented guard, so calling it for a paywall that is live re-bound the view and split the funnel: paywall_open and the load events carried one id, paywall_close and transaction_* another. The keep-existing-id predicate now also covers a presented view. A cached view that is not on screen still mints a fresh id per presentation. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../sdk/paywall/view/PaywallViewState.kt | 10 ++++-- .../sdk/paywall/view/PaywallViewStateTest.kt | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4d82e9..820e4ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Fixes - Fix bug where experiment ID would be stale among paywalls sharing experiments +- Fix `presentation_id` changing mid-presentation when `getPaywall()` is called for a paywall that is already on screen - Fix trial reminder notifications being scheduled after a paywall purchase that did not start a free trial (for example a user who already used their trial and was charged immediately). Reminders are now only scheduled when a trial actually started, and the paywall receives the `freeTrial_start` message for every started trial, even when it has no trial reminders configured. diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt index 1455b13f..12c8a50d 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt @@ -130,10 +130,14 @@ data class PaywallViewState( val experiment: Experiment?, ) : Updates({ state -> // A fresh view is created with the presentation id its load events already carry, - // so the first binding keeps it. Every re-binding of a cached view is a new - // presentation and mints its own id. + // so the first binding keeps it. A view that is currently presented keeps it too: + // it has already emitted paywall_open under that id, and re-binding it (e.g. a + // getPaywall() call for a paywall that is on screen) must not split the funnel + // between paywall_open and the paywall_close/transaction_* events that follow. + // Every re-binding of a cached view that is not on screen is a new presentation + // and mints its own id. val presentationId = - state.paywall.presentationId.takeIf { state.request == null } + state.paywall.presentationId.takeIf { state.request == null || state.isPresented } ?: UUID.randomUUID().toString() state.copy( paywall = diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt index 66bf2a2d..7d742640 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt @@ -670,4 +670,35 @@ class PaywallViewStateTest { } } } + + @Test + fun setRequest_keepsPresentationIdWhileTheViewIsPresented() { + Given("a cached view that is currently presented under a bound request") { + val request = makeRequest() + val fresh = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(makeState()) + val cached = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(fresh) + val presented = PaywallViewState.Updates.SetPresentedAndFinished.transform(cached) + val liveId = presented.info.presentationId + assert(!liveId.isNullOrBlank()) + + When("another request is bound to it mid-presentation") { + val rebound = PaywallViewState.Updates.SetRequest(request, null, null, experiment("B")).transform(presented) + + Then("the live presentation keeps its id but takes the new request's metadata") { + assertEquals(liveId, rebound.info.presentationId) + assertEquals("B", rebound.info.experiment?.id) + } + + When("the presentation ends and the cached view is re-bound") { + val destroyed = PaywallViewState.Updates.CleanupAfterDestroy.transform(rebound) + val next = PaywallViewState.Updates.SetRequest(request, null, null, experiment("C")).transform(destroyed) + + Then("the next presentation mints a new id") { + assert(next.info.presentationId != liveId) + assert(!next.info.presentationId.isNullOrBlank()) + } + } + } + } + } } From 6947840dc4d1afbecd9c2d6ea000dca914254782 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 22 Sep 2026 12:27:50 +0200 Subject: [PATCH 10/15] Own presentationId at the presentation boundary, not at SetRequest Follow-up to 77c13c4f. Keeping the id while `isPresented` was wrong in both directions: the flag is only cleared by a finishing destroyed(), so an embedded host that re-presents a cached view without tearing it down (PaywallHostFragment, RepresentTests) latched it on and reused the first presentation's id forever; and SetRequest still could not tell a live re-binding from the next presentation starting. A presentation is now identified by its paywall_open. SetRequest no longer touches the id. BeginPresentation mints a new one when the current id has already been consumed by an open (or was never set), and runs when a presentation is prepared on a view that is not presented and when a new presentation's onViewCreated() fires, before the Presented state snapshots info. Between two opens the id is stable no matter how often the view is re-bound, so paywall_close and transaction_* always match their open. The consumed flag deliberately survives CleanupAfterDestroy. Rejecting a re-bind at the getPaywall() seam was ruled out: getPaywall() itself marks the view active via prepareToDisplay(), and re-embedding the cached view is a supported pattern the integration tests exercise. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../superwall/sdk/paywall/view/PaywallView.kt | 16 ++- .../sdk/paywall/view/PaywallViewState.kt | 57 +++++--- .../sdk/paywall/view/PaywallViewStateTest.kt | 122 +++++++++++++----- .../sdk/paywall/view/PaywallViewTest.kt | 64 ++++++++- 5 files changed, 206 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 820e4ddf..9d77864c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Fixes - Fix bug where experiment ID would be stale among paywalls sharing experiments -- Fix `presentation_id` changing mid-presentation when `getPaywall()` is called for a paywall that is already on screen +- Fix `presentation_id` changing mid-presentation when `getPaywall()` is called for a paywall that is already on screen. The id now rotates when a cached paywall is presented again, so every event between one `paywall_open` and the next carries the same id. - Fix trial reminder notifications being scheduled after a paywall purchase that did not start a free trial (for example a user who already used their trial and was charged immediately). Reminders are now only scheduled when a trial actually started, and the paywall receives the `freeTrial_start` message for every started trial, even when it has no trial reminders configured. 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 27312c63..0053ed5d 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 @@ -419,6 +419,14 @@ class PaywallView( return } controller.updateState(PresentationWillBegin) + // A cached view that finished its previous presentation is about to start the next one, + // so mint its id now and willPresentPaywall(info) already reports it. Skipped while the + // view is still presented: the cache-hit reset also runs when a live view is re-bound + // (getPaywall() for a paywall that is on screen), and that is not a new presentation. + // onViewCreated() mints instead if a genuinely new presentation follows. + if (!state.isPresented) { + controller.updateState(PaywallViewState.Updates.BeginPresentation) + } factory .delegate() @@ -673,10 +681,16 @@ class PaywallView( // Lets the view know that presentation has finished. // Only called once per presentation. fun onViewCreated() { + val isNewPresentation = !state.presentationDidFinishPrepare + if (isNewPresentation) { + // Before the completion below emits PaywallState.Presented(info): a new presentation + // on a view whose current id was already consumed by a paywall_open gets a fresh one. + controller.updateState(PaywallViewState.Updates.BeginPresentation) + } state.viewCreatedCompletion?.invoke(true) controller.updateState(ClearViewCreatedCompletion) - if (state.presentationDidFinishPrepare) { + if (!isNewPresentation) { if (state.closedForBackground) { controller.updateState(SetClosedForBackground(false)) ioScope.launch { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt index 12c8a50d..e755dffa 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallViewState.kt @@ -48,6 +48,10 @@ data class PaywallViewState( val crashRetries: Int = 0, // / The timestamp when the paywall was opened (presented to user). Used for calculating shimmer visible duration. val lastOpen: Date? = null, + // / `true` once a `paywall_open` has been tracked under the current `paywall.presentationId`. + // / Deliberately survives [Updates.CleanupAfterDestroy]: a consumed id stays consumed, so the + // / next presentation of this cached view mints a new one in [Updates.BeginPresentation]. + val presentationIdOpened: Boolean = false, ) { val info: PaywallInfo get() = @@ -100,8 +104,8 @@ data class PaywallViewState( // campaign that uses the paywall, and this merge runs before the caller binds its // request. A second request for the same paywall (e.g. getPaywall/getPresentationResult // while an implicit presentation is in flight) would otherwise overwrite the metadata - // the first presentation reports. They are bound atomically with the request in - // [SetRequest] instead. + // the first presentation reports. Experiment and source are bound atomically with + // the request in [SetRequest]; the id is owned by [BeginPresentation]. // Update productItems via setter to also refresh related fields. merged.productItems = from.productItems state.copy(paywall = merged) @@ -114,11 +118,15 @@ data class PaywallViewState( }) /** - * Binds a presentation request to the view. The per-presentation metadata - experiment, - * presentation source and presentation id - is bound in the same transition as the - * request so that [PaywallViewState.info] never combines one request's placement with - * another request's experiment or presentation id. This is the only place that metadata - * is written; [MergePaywall] deliberately leaves it alone. + * Binds a presentation request to the view. The experiment and presentation source are + * bound in the same transition as the request so that [PaywallViewState.info] never + * combines one request's placement with another request's experiment. This is the only + * place they are written; [MergePaywall] deliberately leaves them alone. + * + * The presentation id is *not* touched here. Binding a request is not a presentation + * boundary: the same cached view can be re-bound while it is on screen (a `getPaywall()` + * call for a paywall that is already presented), and the view cannot tell that apart from + * the next presentation starting. The id is owned by [BeginPresentation]. * * @param experiment The experiment this request resolved to. Required so a caller cannot * silently inherit whatever the shared cached view was last bound to. @@ -129,22 +137,11 @@ data class PaywallViewState( val occurrence: TriggerRuleOccurrence?, val experiment: Experiment?, ) : Updates({ state -> - // A fresh view is created with the presentation id its load events already carry, - // so the first binding keeps it. A view that is currently presented keeps it too: - // it has already emitted paywall_open under that id, and re-binding it (e.g. a - // getPaywall() call for a paywall that is on screen) must not split the funnel - // between paywall_open and the paywall_close/transaction_* events that follow. - // Every re-binding of a cached view that is not on screen is a new presentation - // and mints its own id. - val presentationId = - state.paywall.presentationId.takeIf { state.request == null || state.isPresented } - ?: UUID.randomUUID().toString() state.copy( paywall = state.paywall.copy( experiment = experiment, presentationSourceType = req.presentationSourceType, - presentationId = presentationId, ), request = req, paywallStatePublisher = publisher, @@ -152,6 +149,28 @@ data class PaywallViewState( ) }) + /** + * Marks the start of a presentation. A presentation is identified by its `paywall_open`: + * if the current id has already been reported by one (see [SetLastOpen]), or the view + * never got one at fetch time, a new id is minted here. Otherwise the id is kept, so a + * fresh view's load events and its first open share the id minted at fetch time. + * + * Idempotent between opens, so it can run both when a presentation is being prepared + * ([PaywallView.beforeViewCreated]) and when the view is created ([PaywallView.onViewCreated]). + * Everything tracked between two opens - `paywall_close`, `transaction_*` - keeps the id + * of the open that preceded it, even if the view was re-bound in between. + */ + object BeginPresentation : Updates({ state -> + if (state.presentationIdOpened || state.paywall.presentationId == null) { + state.copy( + paywall = state.paywall.copy(presentationId = UUID.randomUUID().toString()), + presentationIdOpened = false, + ) + } else { + state + } + }) + class SetPresentationConfig( val styleOverride: PaywallPresentationStyle?, val completion: ((Boolean) -> Unit)?, @@ -251,7 +270,7 @@ data class PaywallViewState( * Used for calculating shimmer visible duration (matching iOS behavior). */ object SetLastOpen : Updates({ state -> - state.copy(lastOpen = Date()) + state.copy(lastOpen = Date(), presentationIdOpened = true) }) object ShimmerEnded : Updates({ state -> diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt index 7d742640..8741b4c9 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewStateTest.kt @@ -644,59 +644,117 @@ class PaywallViewStateTest { } @Test - fun setRequest_mintsPresentationIdPerPresentationOfACachedView() { + fun presentationId_isKeptFromFetchThroughTheFirstOpen() { Given("a freshly created view whose paywall carries the presentation id minted at fetch time") { val state = makeState(Paywall.stub().copy(presentationId = "created")) val request = makeRequest() - When("its first request is bound") { - val first = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(state) + When("its request is bound, a paywall is merged and the presentation begins") { + val bound = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(state) + val merged = PaywallViewState.Updates.MergePaywall(Paywall.stub().copy(presentationId = "other")).transform(bound) + val begun = PaywallViewState.Updates.BeginPresentation.transform(merged) + val opened = PaywallViewState.Updates.SetLastOpen.transform(begun) - Then("the presentation id its load events already carry is kept") { - assertEquals("created", first.info.presentationId) + Then("the id its load events already carry is kept all the way to paywall_open") { + assertEquals("created", bound.info.presentationId) + assertEquals("created", merged.info.presentationId) + assertEquals("created", begun.info.presentationId) + assertEquals("created", opened.info.presentationId) + assert(opened.presentationIdOpened) } + } + } - When("a paywall is merged and a second request is bound to the cached view") { - val merged = PaywallViewState.Updates.MergePaywall(Paywall.stub().copy(presentationId = "other")).transform(first) - val second = PaywallViewState.Updates.SetRequest(request, null, null, experiment("B")).transform(merged) + Given("a view created from a paywall that never got a presentation id") { + val state = makeState(Paywall.stub().copy(presentationId = null)) - Then("the merge leaves the id alone and the re-binding mints a new one") { - assertEquals("created", merged.info.presentationId) - assert(second.info.presentationId != "created") - assert(second.info.presentationId != "other") - assert(!second.info.presentationId.isNullOrBlank()) - } + When("the presentation begins") { + val begun = PaywallViewState.Updates.BeginPresentation.transform(state) + + Then("one is minted so paywall_open never reports a null id") { + assert(!begun.info.presentationId.isNullOrBlank()) } } } } @Test - fun setRequest_keepsPresentationIdWhileTheViewIsPresented() { - Given("a cached view that is currently presented under a bound request") { + fun presentationId_isMintedPerPresentationOfACachedView() { + Given("a cached view that has already been presented once and torn down") { val request = makeRequest() - val fresh = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(makeState()) - val cached = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(fresh) - val presented = PaywallViewState.Updates.SetPresentedAndFinished.transform(cached) - val liveId = presented.info.presentationId - assert(!liveId.isNullOrBlank()) + var state = makeState(Paywall.stub().copy(presentationId = "first")) + state = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(state) + state = PaywallViewState.Updates.BeginPresentation.transform(state) + state = PaywallViewState.Updates.SetPresentedAndFinished.transform(state) + state = PaywallViewState.Updates.SetLastOpen.transform(state) + state = PaywallViewState.Updates.CleanupAfterDestroy.transform(state) + + Then("the consumed id survives the teardown") { + assertEquals("first", state.info.presentationId) + assert(state.presentationIdOpened) + } + + When("it is re-bound and the next presentation begins") { + val rebound = PaywallViewState.Updates.SetRequest(request, null, null, experiment("B")).transform(state) + val begun = PaywallViewState.Updates.BeginPresentation.transform(rebound) + + Then("binding leaves the id alone and beginning the presentation mints a new one") { + assertEquals("first", rebound.info.presentationId) + assert(begun.info.presentationId != "first") + assert(!begun.info.presentationId.isNullOrBlank()) + assert(!begun.presentationIdOpened) + } + + Then("beginning is idempotent until the next open consumes the id") { + val again = PaywallViewState.Updates.BeginPresentation.transform(begun) + assertEquals(begun.info.presentationId, again.info.presentationId) + } + } + } + + Given("an embedded view that is re-presented without ever being torn down") { + var state = makeState(Paywall.stub().copy(presentationId = "first")) + state = PaywallViewState.Updates.BeginPresentation.transform(state) + state = PaywallViewState.Updates.SetPresentedAndFinished.transform(state) + state = PaywallViewState.Updates.SetLastOpen.transform(state) + // The cache-hit reset PaywallManager runs for every new presentation. + state = PaywallViewState.Updates.ResetPresentationPreparations.transform(state) + + When("the second presentation begins") { + val begun = PaywallViewState.Updates.BeginPresentation.transform(state) + + Then("it does not reuse the first presentation's id") { + assert(state.isPresented) + assert(begun.info.presentationId != "first") + } + } + } + } + + @Test + fun presentationId_isStableWhileAPresentationIsLive() { + Given("a presented view that has tracked paywall_open") { + val request = makeRequest() + var state = makeState(Paywall.stub().copy(presentationId = "live")) + state = PaywallViewState.Updates.SetRequest(request, null, null, experiment("A")).transform(state) + state = PaywallViewState.Updates.BeginPresentation.transform(state) + state = PaywallViewState.Updates.SetPresentedAndFinished.transform(state) + state = PaywallViewState.Updates.SetLastOpen.transform(state) When("another request is bound to it mid-presentation") { - val rebound = PaywallViewState.Updates.SetRequest(request, null, null, experiment("B")).transform(presented) + val rebound = PaywallViewState.Updates.SetRequest(request, null, null, experiment("B")).transform(state) - Then("the live presentation keeps its id but takes the new request's metadata") { - assertEquals(liveId, rebound.info.presentationId) - assertEquals("B", rebound.info.experiment?.id) + Then("the live presentation keeps its id") { + assertEquals("live", rebound.info.presentationId) } + } - When("the presentation ends and the cached view is re-bound") { - val destroyed = PaywallViewState.Updates.CleanupAfterDestroy.transform(rebound) - val next = PaywallViewState.Updates.SetRequest(request, null, null, experiment("C")).transform(destroyed) + When("it is closed for background and opened again") { + val closed = PaywallViewState.Updates.SetClosedForBackground(true).transform(state) + val reopened = PaywallViewState.Updates.SetLastOpen.transform(closed) - Then("the next presentation mints a new id") { - assert(next.info.presentationId != liveId) - assert(!next.info.presentationId.isNullOrBlank()) - } + Then("the resumed presentation keeps its id") { + assertEquals("live", reopened.info.presentationId) } } } 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 d1e018ba..39476528 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 @@ -767,8 +767,11 @@ class PaywallViewTest { } } - private fun makePaywallView(cache: com.superwall.sdk.paywall.manager.PaywallViewCache?): PaywallView { - val state = PaywallViewState(paywall = Paywall.stub(), locale = "en-US") + private fun makePaywallView( + cache: com.superwall.sdk.paywall.manager.PaywallViewCache?, + paywall: Paywall = Paywall.stub(), + ): PaywallView { + val state = PaywallViewState(paywall = paywall, locale = "en-US") val controller = PaywallView.PaywallController(state) return PaywallView( context = context, @@ -1432,4 +1435,61 @@ class PaywallViewTest { ), ) } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun onViewCreated_mintsPresentationIdOnlyForANewPresentation() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + try { + Given("a PaywallView presented once, whose paywall_open consumed the fetch-time id") { + clearMocks(delegateAdapter, answers = false) + val view = makePaywallView(cache = null, paywall = Paywall.stub().copy(presentationId = "first")) + + val trackedEvents = + java.util.Collections.synchronizedList( + mutableListOf(), + ) + // Counts down once per paywall_open; the test expects exactly two. + val openLatch = CountDownLatch(2) + captureTrackedEvents(trackedEvents, CountDownLatch(1), openLatch) + + view.onViewCreated() + advanceUntilIdle() + assertEquals("first", view.state.info.presentationId) + + When("the Activity resumes the same presentation: onViewCreated() again") { + view.onViewCreated() + advanceUntilIdle() + + Then("the id is untouched") { + assertEquals("first", view.state.info.presentationId) + } + } + + When("a new presentation starts on the cached view (cache-hit reset, then onViewCreated)") { + var presentedInfoId: String? = null + view.controller.updateState( + PaywallViewState.Updates.SetPresentationConfig(null) { presentedInfoId = view.info.presentationId }, + ) + view.resetTransientPresentationState() + view.onViewCreated() + advanceUntilIdle() + + Then("a new id is minted before the Presented completion and the second open reports it") { + val second = view.state.info.presentationId + assert(second != "first") { "Expected a new presentation id, got $second" } + assertEquals(second, presentedInfoId) + assertTrue( + "Expected two PaywallOpen events, got $trackedEvents", + openLatch.await(2, TimeUnit.SECONDS), + ) + } + } + } + } finally { + Dispatchers.resetMain() + } + } } From 8ee5b8a3d5f1a3212cee3801075d624ae4ec34ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Sep 2026 10:46:38 +0000 Subject: [PATCH 11/15] 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 1ae93b72..6e67cc64 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.2% \ No newline at end of file +coverage47.3% \ No newline at end of file From 28349b95060db90c18ebc67cda72b936f9a36bbe Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 22 Sep 2026 12:58:33 +0200 Subject: [PATCH 12/15] Leave a live presented view alone on cache hit; mint id at one predicate Follow-up to 6947840d, addressing review: - The two BeginPresentation sites disagreed: presentationWillBegin() also required !isPresented, so on the embedded re-present path (cache-hit reset clears the prepare flag while isPresented stays latched) willPresentPaywall reported the previous, already-consumed id while didPresentPaywall and paywall_open reported the fresh one. Both sites now run under the same guard (a new presentation, i.e. presentationDidFinishPrepare == false). - The reason that guard existed - a getPaywall() for a paywall that is on screen must not start a second presentation on the live view - is handled where it originates instead. resetTransientPresentationState() refuses to reset a view that is presented AND attached to a window. Such a view is a live presentation, not a cached view being re-presented; resetting it would make its next onResume -> onViewCreated() re-run the flow, fire a second paywall_open and rotate the id away from the pending paywall_close. isPresented alone cannot be used: an embedded host that re-presents a cached view detaches it first without tearing it down (PaywallHostFragment), which is the case the reset exists for. - Tests: the onViewCreated id test now awaits the first paywall_open, which runs on the real IO scope, before relying on the consumed flag. Two new tests drive beforeViewCreated(): a detached re-present reports one new id to willPresentPaywall, didPresentPaywall and paywall_open; a live attached view handed back by the cache keeps its id, prepare flag and open count. Co-Authored-By: Claude Fable 5.1 --- .../sdk/paywall/manager/PaywallManager.kt | 3 + .../superwall/sdk/paywall/view/PaywallView.kt | 25 ++-- .../sdk/paywall/view/PaywallViewTest.kt | 128 ++++++++++++++++++ 3 files changed, 148 insertions(+), 8 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt index d8d7f8b5..8f309678 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt @@ -90,6 +90,9 @@ class PaywallManager( // isForPresentation because getPresentationResult() (a pure query API) // also fetches through here — a result check while this paywall is // on screen mid-purchase must not wipe its live spinner or prepare flags. + // The view itself also refuses the reset while it is presented and + // attached to a window, so a getPaywall() for a paywall that is on + // screen hands the live view back without starting a new presentation. if (isForPresentation) { view.resetTransientPresentationState() } 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 0053ed5d..09cc4734 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 @@ -419,14 +419,11 @@ class PaywallView( return } controller.updateState(PresentationWillBegin) - // A cached view that finished its previous presentation is about to start the next one, - // so mint its id now and willPresentPaywall(info) already reports it. Skipped while the - // view is still presented: the cache-hit reset also runs when a live view is re-bound - // (getPaywall() for a paywall that is on screen), and that is not a new presentation. - // onViewCreated() mints instead if a genuinely new presentation follows. - if (!state.isPresented) { - controller.updateState(PaywallViewState.Updates.BeginPresentation) - } + // A new presentation is being prepared on this view (the guard above excludes a live + // one), so mint its id now: willPresentPaywall(info), didPresentPaywall(info) and the + // paywall_open that follows must all report the same id. onViewCreated() runs the same + // idempotent update for hosts that skip beforeViewCreated(). + controller.updateState(PaywallViewState.Updates.BeginPresentation) factory .delegate() @@ -531,7 +528,19 @@ class PaywallView( // Safe because that branch only runs for a genuinely new presentation request - never on // resume-same-instance (that goes through onResume -> onViewCreated) nor during an in-flight // purchase. + // + // A view that is presented AND attached to a window is a live presentation, not a cached + // view being re-presented: the caller asked for a paywall that is on screen (a getPaywall() + // for it mid-presentation). Resetting it would start a second presentation on the live view - + // its next onResume -> onViewCreated() would re-run the flow, fire another paywall_open and + // rotate its presentation id away from the pending paywall_close. So it is left alone. + // `isPresented` alone is not enough: it is only cleared by a finishing teardown, and an + // embedded host that re-presents a cached view detaches it first without tearing it down + // (PaywallHostFragment), which is exactly the case this reset exists for. internal fun resetTransientPresentationState() { + if (state.isPresented && isAttachedToWindow) { + return + } if (loadingState is PaywallLoadingState.LoadingPurchase || loadingState is PaywallLoadingState.ManualLoading ) { 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 39476528..3abf66dd 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 @@ -1457,6 +1457,9 @@ class PaywallViewTest { view.onViewCreated() advanceUntilIdle() + // trackOpen() runs on the real IO scope, so wait for the first open to land. + assertTrue("Precondition: first paywall_open tracked", waitUntil { openLatch.count == 1L }) + assertTrue("Precondition: id consumed by the open", view.state.presentationIdOpened) assertEquals("first", view.state.info.presentationId) When("the Activity resumes the same presentation: onViewCreated() again") { @@ -1492,4 +1495,129 @@ class PaywallViewTest { Dispatchers.resetMain() } } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun rePresentingADetachedCachedView_reportsOneNewPresentationIdToWillDidAndOpen() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + try { + Given("an embedded PaywallView presented once and detached without a finishing teardown") { + clearMocks(delegateAdapter, answers = false) + val view = makePaywallView(cache = null, paywall = Paywall.stub().copy(presentationId = "first")) + val willInfos = mutableListOf() + val didInfos = mutableListOf() + every { delegateAdapter.willPresentPaywall(capture(willInfos)) } just Runs + every { delegateAdapter.didPresentPaywall(capture(didInfos)) } just Runs + + val trackedEvents = + java.util.Collections.synchronizedList( + mutableListOf(), + ) + val openLatch = CountDownLatch(2) + captureTrackedEvents(trackedEvents, CountDownLatch(1), openLatch) + + view.beforeViewCreated() + view.onViewCreated() + advanceUntilIdle() + assertTrue("Precondition: first paywall_open tracked", waitUntil { openLatch.count == 1L }) + assertEquals("first", willInfos.single().presentationId) + assertEquals("first", didInfos.single().presentationId) + assertTrue("Precondition: embedded host never tears the view down", view.state.isPresented) + + When("the host re-embeds it: cache-hit reset, beforeViewCreated(), onViewCreated()") { + view.resetTransientPresentationState() + view.beforeViewCreated() + val idAfterWillPresent = view.state.info.presentationId + view.onViewCreated() + advanceUntilIdle() + assertTrue("Second paywall_open tracked", openLatch.await(2, TimeUnit.SECONDS)) + + Then("willPresentPaywall, didPresentPaywall and paywall_open all carry the same new id") { + val second = view.state.info.presentationId + assert(second != "first") { "Expected a new presentation id, got $second" } + assertEquals(second, idAfterWillPresent) + assertEquals(second, willInfos[1].presentationId) + assertEquals(second, didInfos[1].presentationId) + val opens = + trackedEvents.filterIsInstance() + assertEquals(listOf("first", second), opens.map { it.paywallInfo.presentationId }) + } + } + } + } finally { + Dispatchers.resetMain() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun reBindingALivePresentedView_doesNotStartANewPresentation() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + try { + Given("a PaywallView that is presented and attached to a window") { + clearMocks(delegateAdapter, answers = false) + val view = makePaywallView(cache = null, paywall = Paywall.stub().copy(presentationId = "live")) + val activity = + org.robolectric.Robolectric + .buildActivity(android.app.Activity::class.java) + .setup() + .get() + activity.setContentView(view) + assertTrue("Precondition: attached to a window", view.isAttachedToWindow) + + val trackedEvents = + java.util.Collections.synchronizedList( + mutableListOf(), + ) + val openLatch = CountDownLatch(1) + captureTrackedEvents(trackedEvents, CountDownLatch(1), openLatch) + + view.beforeViewCreated() + view.onViewCreated() + advanceUntilIdle() + assertTrue("Precondition: paywall_open tracked", openLatch.await(2, TimeUnit.SECONDS)) + assertEquals("live", view.state.info.presentationId) + clearMocks(delegateAdapter, answers = false) + + When("getPaywall() hands the live view back: cache-hit reset, beforeViewCreated(), onViewCreated()") { + view.resetTransientPresentationState() + view.beforeViewCreated() + view.onViewCreated() + advanceUntilIdle() + + Then("the live presentation is untouched: same id, no second open, no delegate callbacks") { + assertTrue("prepare flag must survive", view.state.presentationDidFinishPrepare) + assertEquals("live", view.state.info.presentationId) + Thread.sleep(300) + assertEquals( + 1, + trackedEvents.count { + it is com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.PaywallOpen + }, + ) + verify(exactly = 0) { delegateAdapter.willPresentPaywall(any()) } + verify(exactly = 0) { delegateAdapter.didPresentPaywall(any()) } + } + } + + When("the host detaches it and re-presents it") { + (view.parent as android.view.ViewGroup).removeView(view) + assertFalse("Precondition: detached", view.isAttachedToWindow) + view.resetTransientPresentationState() + view.beforeViewCreated() + + Then("that is a new presentation and mints a new id") { + assertFalse(view.state.presentationDidFinishPrepare) + assert(view.state.info.presentationId != "live") + } + } + } + } finally { + Dispatchers.resetMain() + } + } } From f64576282da19d22f6b84eda1d32b922265725fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Sep 2026 11:18:48 +0000 Subject: [PATCH 13/15] 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 08ef66c7..89452688 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.4% \ No newline at end of file +branches38.6% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 6e67cc64..88477227 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.3% \ No newline at end of file +coverage47.4% \ No newline at end of file From 2fb528e6fd2fc15dbb418f19467e7d2f36ff2383 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 22 Sep 2026 13:31:58 +0200 Subject: [PATCH 14/15] getPaywall() for an on-screen paywall takes the view over as a new presentation Follow-up to 28349b95, addressing review. The isAttachedToWindow guard in resetTransientPresentationState() could not tell "the live presentation is being re-bound" from "a different request wants this paywall while it is on screen", and picked the worst outcome for the latter: no spinner reset, no prepare-flag reset, so the new host got a view that emitted no willPresentPaywall, no didPresentPaywall, no paywall_open and no fresh id, with a possibly stale LoadingPurchase spinner (the #434 dead-buy-button bug). Meanwhile prepareToDisplay() detached the view from its old parent in the same call, so the old presentation was not being left alone either. The guard is gone. The only request that reaches the cache-hit reset while a paywall is on screen is a getPaywall() for that same paywall (register() is rejected earlier by checkNoPaywallAlreadyPresented), and that call always takes the view over. So there is no live re-binding to protect: the takeover is the next presentation of the view, gets a clean slate and a new id like any other, and the two mint sites keep their shared predicate. The taken-over presentation ends without a paywall_close of its own, as before. The "live re-bind keeps everything" test is replaced by one that attaches the view to a Robolectric activity, puts it mid-purchase, runs the takeover, and checks the reset happened and willPresent, didPresent and paywall_open all report one new id. Co-Authored-By: Claude Fable 5.1 --- .../sdk/paywall/manager/PaywallManager.kt | 6 +-- .../superwall/sdk/paywall/view/PaywallView.kt | 27 +++++----- .../sdk/paywall/view/PaywallViewTest.kt | 53 +++++++++---------- 3 files changed, 39 insertions(+), 47 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt index 8f309678..a4304f7c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt @@ -90,9 +90,9 @@ class PaywallManager( // isForPresentation because getPresentationResult() (a pure query API) // also fetches through here — a result check while this paywall is // on screen mid-purchase must not wipe its live spinner or prepare flags. - // The view itself also refuses the reset while it is presented and - // attached to a window, so a getPaywall() for a paywall that is on - // screen hands the live view back without starting a new presentation. + // A getPaywall() for a paywall that is currently on screen also lands + // here: it takes the view over (prepareToDisplay() detaches it from + // its current parent), so it is a new presentation like any other. if (isForPresentation) { view.resetTransientPresentationState() } 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 09cc4734..d90d6879 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 @@ -419,10 +419,10 @@ class PaywallView( return } controller.updateState(PresentationWillBegin) - // A new presentation is being prepared on this view (the guard above excludes a live - // one), so mint its id now: willPresentPaywall(info), didPresentPaywall(info) and the - // paywall_open that follows must all report the same id. onViewCreated() runs the same - // idempotent update for hosts that skip beforeViewCreated(). + // A new presentation is being prepared on this view (the guard above excludes one that + // already finished preparing), so mint its id now: willPresentPaywall(info), + // didPresentPaywall(info) and the paywall_open that follows must all report the same id. + // onViewCreated() runs the same idempotent update for hosts that skip beforeViewCreated(). controller.updateState(PaywallViewState.Updates.BeginPresentation) factory @@ -529,18 +529,15 @@ class PaywallView( // resume-same-instance (that goes through onResume -> onViewCreated) nor during an in-flight // purchase. // - // A view that is presented AND attached to a window is a live presentation, not a cached - // view being re-presented: the caller asked for a paywall that is on screen (a getPaywall() - // for it mid-presentation). Resetting it would start a second presentation on the live view - - // its next onResume -> onViewCreated() would re-run the flow, fire another paywall_open and - // rotate its presentation id away from the pending paywall_close. So it is left alone. - // `isPresented` alone is not enough: it is only cleared by a finishing teardown, and an - // embedded host that re-presents a cached view detaches it first without tearing it down - // (PaywallHostFragment), which is exactly the case this reset exists for. + // This also runs when the view is still presented and attached: the only request that reaches + // the cache-hit branch while a paywall is on screen is a getPaywall() for that same paywall + // (register() is rejected earlier by checkNoPaywallAlreadyPresented), and that call always + // takes the view over - prepareToDisplay() removes it from its current parent in the same + // call. The view cannot tell that apart from an embedded host re-presenting a detached view, + // and it does not need to: both are the next presentation of this view, so both get a clean + // slate and a new presentation id (minted in beforeViewCreated()/onViewCreated()). The + // presentation being taken over ends here without a paywall_close of its own. internal fun resetTransientPresentationState() { - if (state.isPresented && isAttachedToWindow) { - return - } if (loadingState is PaywallLoadingState.LoadingPurchase || loadingState is PaywallLoadingState.ManualLoading ) { 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 3abf66dd..b39582ad 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 @@ -1553,12 +1553,12 @@ class PaywallViewTest { @OptIn(ExperimentalCoroutinesApi::class) @Test - fun reBindingALivePresentedView_doesNotStartANewPresentation() = + fun handingALiveAttachedViewToANewPresentation_resetsItAndMintsANewId() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) Dispatchers.setMain(dispatcher) try { - Given("a PaywallView that is presented and attached to a window") { + Given("a PaywallView that is presented, attached to a window and mid-purchase") { clearMocks(delegateAdapter, answers = false) val view = makePaywallView(cache = null, paywall = Paywall.stub().copy(presentationId = "live")) val activity = @@ -1568,51 +1568,46 @@ class PaywallViewTest { .get() activity.setContentView(view) assertTrue("Precondition: attached to a window", view.isAttachedToWindow) + val willInfos = mutableListOf() + val didInfos = mutableListOf() + every { delegateAdapter.willPresentPaywall(capture(willInfos)) } just Runs + every { delegateAdapter.didPresentPaywall(capture(didInfos)) } just Runs val trackedEvents = java.util.Collections.synchronizedList( mutableListOf(), ) - val openLatch = CountDownLatch(1) + val openLatch = CountDownLatch(2) captureTrackedEvents(trackedEvents, CountDownLatch(1), openLatch) view.beforeViewCreated() view.onViewCreated() advanceUntilIdle() - assertTrue("Precondition: paywall_open tracked", openLatch.await(2, TimeUnit.SECONDS)) + assertTrue("Precondition: first paywall_open tracked", waitUntil { openLatch.count == 1L }) assertEquals("live", view.state.info.presentationId) - clearMocks(delegateAdapter, answers = false) + view.controller.updateState(PaywallViewState.Updates.SetLoadingState(PaywallLoadingState.LoadingPurchase)) - When("getPaywall() hands the live view back: cache-hit reset, beforeViewCreated(), onViewCreated()") { + When("getPaywall() for this paywall takes it over: cache-hit reset, beforeViewCreated(), onViewCreated()") { view.resetTransientPresentationState() - view.beforeViewCreated() - view.onViewCreated() - advanceUntilIdle() - Then("the live presentation is untouched: same id, no second open, no delegate callbacks") { - assertTrue("prepare flag must survive", view.state.presentationDidFinishPrepare) - assertEquals("live", view.state.info.presentationId) - Thread.sleep(300) - assertEquals( - 1, - trackedEvents.count { - it is com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.PaywallOpen - }, - ) - verify(exactly = 0) { delegateAdapter.willPresentPaywall(any()) } - verify(exactly = 0) { delegateAdapter.didPresentPaywall(any()) } + Then("the stale spinner and prepare flag are cleared even though it is still on screen") { + assertTrue(view.loadingState is PaywallLoadingState.Ready) + assertFalse(view.state.presentationDidFinishPrepare) } - } - When("the host detaches it and re-presents it") { - (view.parent as android.view.ViewGroup).removeView(view) - assertFalse("Precondition: detached", view.isAttachedToWindow) - view.resetTransientPresentationState() view.beforeViewCreated() + view.onViewCreated() + advanceUntilIdle() + assertTrue("Second paywall_open tracked", openLatch.await(2, TimeUnit.SECONDS)) - Then("that is a new presentation and mints a new id") { - assertFalse(view.state.presentationDidFinishPrepare) - assert(view.state.info.presentationId != "live") + Then("it is a new presentation: one new id reported to willPresent, didPresent and paywall_open") { + val second = view.state.info.presentationId + assert(second != "live") { "Expected a new presentation id, got $second" } + assertEquals(second, willInfos[1].presentationId) + assertEquals(second, didInfos[1].presentationId) + val opens = + trackedEvents.filterIsInstance() + assertEquals(listOf("live", second), opens.map { it.paywallInfo.presentationId }) } } } From fe4013d4a448ae1af681a13f0de9e6c36d9d67c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Sep 2026 11:50:55 +0000 Subject: [PATCH 15/15] 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 89452688..ebe2b57e 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.6% \ No newline at end of file +branches38.8% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 88477227..302e4d57 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.4% \ No newline at end of file +coverage47.7% \ No newline at end of file