diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index c0c8757f6..ebe2b57eb 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index f0c8dbed0..302e4d57d 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46cd37f60..9d77864c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.8.4
+
+## 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. 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.
+
+
## 2.8.3
## Fixes
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 63f6309ea..c0bc46759 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/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
index 909f00961..eebad9ea9 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 fe6b0d72a..da7d94c79 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,13 @@ 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 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()) {
@@ -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,
+ ),
)
}
},
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 d8d7f8b54..a4304f7c0 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.
+ // 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/presentation/get_paywall/InternalGetPaywall.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_paywall/InternalGetPaywall.kt
index c09827709..d6a6e8031 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?,
)
/**
@@ -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 b07b14752..341b9c5e0 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 0cf3993ca..4e7707165 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 618d7e4ce..1d9d3213e 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?,
) {
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)
@@ -112,6 +116,7 @@ fun Superwall.presentPaywallViewSync(
unsavedOccurrence: TriggerRuleOccurrence?,
debugInfo: Map,
request: PresentationRequest,
+ experiment: Experiment?,
onStateChanged: (PaywallState) -> Unit,
) {
mainScope.launch {
@@ -128,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 d33dddb42..d90d6879b 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?,
) {
controller.updateState(
SetRequest(
request,
paywallStatePublisher,
unsavedOccurrence,
+ experiment,
),
)
}
@@ -303,6 +306,7 @@ class PaywallView(
unsavedOccurrence: TriggerRuleOccurrence?,
presentationStyleOverride: PaywallPresentationStyle?,
paywallStatePublisher: MutableSharedFlow,
+ experiment: Experiment?,
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,
@@ -415,6 +419,11 @@ class PaywallView(
return
}
controller.updateState(PresentationWillBegin)
+ // 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
.delegate()
@@ -519,6 +528,15 @@ 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.
+ //
+ // 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 (loadingState is PaywallLoadingState.LoadingPurchase ||
loadingState is PaywallLoadingState.ManualLoading
@@ -669,10 +687,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 7f6d00311..e755dffa2 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
@@ -14,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,
@@ -46,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() =
@@ -92,9 +98,14 @@ data class PaywallViewState(
swProductVariablesTemplate = from.swProductVariablesTemplate,
isFreeTrialAvailable = from.isFreeTrialAvailable,
productsLoadingInfo = from.productsLoadingInfo,
- presentationSourceType = from.presentationSourceType,
- experiment = from.experiment,
)
+ // 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. 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)
@@ -106,18 +117,60 @@ data class PaywallViewState(
state.copy(customerInfo = customerInfo)
})
+ /**
+ * 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.
+ */
class SetRequest(
val req: PresentationRequest,
val publisher: MutableSharedFlow?,
val occurrence: TriggerRuleOccurrence?,
+ val experiment: Experiment?,
) : Updates({ state ->
state.copy(
+ paywall =
+ state.paywall.copy(
+ experiment = experiment,
+ presentationSourceType = req.presentationSourceType,
+ ),
request = req,
paywallStatePublisher = publisher,
unsavedOccurrence = occurrence,
)
})
+ /**
+ * 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)?,
@@ -217,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/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
index 5d95f9e20..bf50f8e98 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 000000000..6575dff4a
--- /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/paywall/manager/PaywallManagerExperimentIsolationTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/manager/PaywallManagerExperimentIsolationTest.kt
new file mode 100644
index 000000000..f690dec4a
--- /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 57aaf8ea7..8741b4c9c 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
@@ -470,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)
@@ -561,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)
@@ -587,4 +588,175 @@ 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 with a null experiment") {
+ val newState = PaywallViewState.Updates.SetRequest(request, null, null, null).transform(state)
+
+ Then("the stale experiment is not inherited") {
+ assertEquals(null, newState.info.experiment)
+ }
+ }
+ }
+ }
+
+ @Test
+ 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 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 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)
+ }
+ }
+ }
+
+ Given("a view created from a paywall that never got a presentation id") {
+ val state = makeState(Paywall.stub().copy(presentationId = null))
+
+ 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 presentationId_isMintedPerPresentationOfACachedView() {
+ Given("a cached view that has already been presented once and torn down") {
+ val request = makeRequest()
+ 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(state)
+
+ Then("the live presentation keeps its id") {
+ assertEquals("live", rebound.info.presentationId)
+ }
+ }
+
+ 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 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 125f239c5..b39582ad1 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,
),
)
@@ -766,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,
@@ -843,6 +847,7 @@ class PaywallViewTest {
req = mockk(relaxed = true),
publisher = statePublisher,
occurrence = null,
+ experiment = null,
),
)
view.controller.updateState(PaywallViewState.Updates.SetPresentedAndFinished)
@@ -1035,6 +1040,7 @@ class PaywallViewTest {
req = mockk(relaxed = true),
publisher = statePublisher,
occurrence = null,
+ experiment = null,
),
)
view.controller.updateState(PaywallViewState.Updates.SetPresentedAndFinished)
@@ -1429,4 +1435,184 @@ 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()
+ // 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") {
+ 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()
+ }
+ }
+
+ @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 handingALiveAttachedViewToANewPresentation_resetsItAndMintsANewId() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ try {
+ 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 =
+ org.robolectric.Robolectric
+ .buildActivity(android.app.Activity::class.java)
+ .setup()
+ .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(2)
+ captureTrackedEvents(trackedEvents, CountDownLatch(1), openLatch)
+
+ view.beforeViewCreated()
+ view.onViewCreated()
+ advanceUntilIdle()
+ assertTrue("Precondition: first paywall_open tracked", waitUntil { openLatch.count == 1L })
+ assertEquals("live", view.state.info.presentationId)
+ view.controller.updateState(PaywallViewState.Updates.SetLoadingState(PaywallLoadingState.LoadingPurchase))
+
+ When("getPaywall() for this paywall takes it over: cache-hit reset, beforeViewCreated(), onViewCreated()") {
+ view.resetTransientPresentationState()
+
+ 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)
+ }
+
+ view.beforeViewCreated()
+ view.onViewCreated()
+ advanceUntilIdle()
+ assertTrue("Second paywall_open tracked", openLatch.await(2, TimeUnit.SECONDS))
+
+ 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 })
+ }
+ }
+ }
+ } finally {
+ Dispatchers.resetMain()
+ }
+ }
}
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 c3a2888c1..a6e9885a6 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 000000000..3eb6d5c8c
--- /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())
+ }
+ }
+ }
+ }
+}
diff --git a/version.env b/version.env
index 264f00735..19601be3a 100644
--- a/version.env
+++ b/version.env
@@ -1 +1 @@
-SUPERWALL_VERSION=2.8.3
+SUPERWALL_VERSION=2.8.4