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 @@ -branches38.2% \ 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 f0c8dbed0..302e4d57d 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.1% \ No newline at end of file +coverage47.7% \ 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