diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 803f64d56..7392eda28 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index a08d53e80..8e021ed1c 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 7acb6102c..d4a6066a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw
## Unreleased
+- Web purchase redemption now exposes the full checkout product in `didRedeemLink`, tracks `freeTrial_start` once per code, and schedules the active paywall's trial reminders from the checkout timestamp. Notification permission waits no longer block access or drop a late grant; ambiguous or already-elapsed reminders are skipped.
- Fix multi-page paywalls only reporting the entry page view. `paywall_open` now waits for an in-flight `template_variables` send, so the runtime does not treat a late template payload as a fresh load and drop later `page_view`s.
- Fix an active paywall not being reopened after its webview process crashes and is recreated. Recovery cancels template work for the old document and sends the open after the replacement loads, only if the same presentation is still active.
- Fix prices not showing when product/offers are fetched from cache
diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts
index 7561d19d4..d0f60db23 100644
--- a/superwall/build.gradle.kts
+++ b/superwall/build.gradle.kts
@@ -85,6 +85,10 @@ android {
buildConfig = true
}
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
+
kotlinOptions {
jvmTarget = "17"
}
diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
index 9b3b2228d..fe6b0d72a 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -61,6 +61,7 @@ import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.models.entitlements.TransactionReceipt
import com.superwall.sdk.models.events.EventData
import com.superwall.sdk.models.internal.VendorId
+import com.superwall.sdk.models.paywall.LocalNotification
import com.superwall.sdk.models.paywall.LocalNotificationType
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.product.ProductVariable
@@ -132,10 +133,12 @@ import com.superwall.sdk.utilities.ErrorTracker
import com.superwall.sdk.utilities.dateFormat
import com.superwall.sdk.web.DeepLinkReferrer
import com.superwall.sdk.web.WebPaywallRedeemer
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import com.superwall.sdk.models.serialization.DateSerializer
import kotlinx.serialization.json.ClassDiscriminatorMode
import kotlinx.serialization.json.Json
@@ -1308,6 +1311,24 @@ class DependencyContainer(
}
}
+ override suspend fun scheduleTrialNotifications(notifications: List) {
+ withContext(Dispatchers.Main.immediate) {
+ val paywallView = Superwall.instance.paywallView ?: return@withContext
+ val activity =
+ (paywallView.encapsulatingActivity?.get() ?: activityProvider?.getCurrentActivity())
+ as? SuperwallPaywallActivity ?: return@withContext
+ if (!activity.isFinishing && !activity.isDestroyed) {
+ // Web reminders already use an absolute checkout timestamp, including in sandbox.
+ activity.attemptToScheduleNotifications(
+ notifications,
+ this@DependencyContainer,
+ cancelExisting = false,
+ applySandboxScaling = false,
+ )
+ }
+ }
+ }
+
override fun isPaymentSheetOpen(): Boolean {
// TODO: Track payment sheet state
return false
diff --git a/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt b/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt
index 5b921dec3..b2bc84d6e 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt
@@ -125,20 +125,146 @@ sealed class RedemptionResult {
) : RedemptionResult()
@Serializable
- data class PaywallInfo(
- @SerialName("identifier")
- val identifier: PaywallIdentifier,
- @SerialName("placementName")
- val placementName: String,
- @SerialName("placementParams")
- val placementParams: Map,
- @SerialName("variantId")
- val variantId: VariantId,
- @SerialName("experimentId")
- val experimentId: ExperimentId,
- @SerialName("productIdentifier")
- val productIdentifier: String? = null,
- )
+ class PaywallInfo
+ @JvmOverloads
+ constructor(
+ @SerialName("identifier") val identifier: PaywallIdentifier,
+ @SerialName("placementName") val placementName: String,
+ @SerialName("placementParams") val placementParams: Map,
+ @SerialName("variantId") val variantId: VariantId,
+ @SerialName("experimentId") val experimentId: ExperimentId,
+ @SerialName("productIdentifier") val productIdentifier: String? = null,
+ ) {
+ /** Original checkout variables. Kept outside the constructor to preserve the Kotlin JVM ABI. */
+ @SerialName("product")
+ var product: PaywallProduct? = null
+ private set
+
+ constructor(
+ identifier: PaywallIdentifier,
+ placementName: String,
+ placementParams: Map,
+ variantId: VariantId,
+ experimentId: ExperimentId,
+ productIdentifier: String? = null,
+ product: PaywallProduct?,
+ ) : this(identifier, placementName, placementParams, variantId, experimentId, productIdentifier) {
+ this.product = product
+ }
+
+ // Retain the original copy/copy$default and component signatures for precompiled Kotlin callers.
+ fun copy(
+ identifier: PaywallIdentifier = this.identifier,
+ placementName: String = this.placementName,
+ placementParams: Map = this.placementParams,
+ variantId: VariantId = this.variantId,
+ experimentId: ExperimentId = this.experimentId,
+ productIdentifier: String? = this.productIdentifier,
+ ): PaywallInfo = PaywallInfo(identifier, placementName, placementParams, variantId, experimentId, productIdentifier, product)
+
+ fun copy(
+ identifier: PaywallIdentifier = this.identifier,
+ placementName: String = this.placementName,
+ placementParams: Map = this.placementParams,
+ variantId: VariantId = this.variantId,
+ experimentId: ExperimentId = this.experimentId,
+ productIdentifier: String? = this.productIdentifier,
+ product: PaywallProduct?,
+ ): PaywallInfo = PaywallInfo(identifier, placementName, placementParams, variantId, experimentId, productIdentifier, product)
+
+ operator fun component1(): PaywallIdentifier = identifier
+
+ operator fun component2(): String = placementName
+
+ operator fun component3(): Map = placementParams
+
+ operator fun component4(): VariantId = variantId
+
+ operator fun component5(): ExperimentId = experimentId
+
+ operator fun component6(): String? = productIdentifier
+
+ operator fun component7(): PaywallProduct? = product
+
+ override fun equals(other: Any?): Boolean =
+ other is PaywallInfo &&
+ identifier == other.identifier && placementName == other.placementName &&
+ placementParams == other.placementParams && variantId == other.variantId &&
+ experimentId == other.experimentId && productIdentifier == other.productIdentifier && product == other.product
+
+ override fun hashCode(): Int =
+ listOf(identifier, placementName, placementParams, variantId, experimentId, productIdentifier, product).hashCode()
+
+ override fun toString(): String =
+ "PaywallInfo(identifier=$identifier, placementName=$placementName, placementParams=$placementParams, " +
+ "variantId=$variantId, experimentId=$experimentId, productIdentifier=$productIdentifier, product=$product)"
+
+ @Serializable
+ data class PaywallProduct(
+ @SerialName("identifier")
+ val identifier: String,
+ @SerialName("languageCode")
+ val languageCode: String = "",
+ @SerialName("locale")
+ val locale: String = "",
+ @SerialName("currencyCode")
+ val currencyCode: String = "",
+ @SerialName("currencySymbol")
+ val currencySymbol: String = "",
+ @SerialName("period")
+ val period: String = "",
+ @SerialName("periodly")
+ val periodly: String = "",
+ @SerialName("localizedPeriod")
+ val localizedPeriod: String = "",
+ @SerialName("periodAlt")
+ val periodAlt: String = "",
+ @SerialName("periodDays")
+ val periodDays: Int = 0,
+ @SerialName("periodWeeks")
+ val periodWeeks: Int = 0,
+ @SerialName("periodMonths")
+ val periodMonths: Int = 0,
+ @SerialName("periodYears")
+ val periodYears: Int = 0,
+ @SerialName("rawPrice")
+ val rawPrice: Double = 0.0,
+ @SerialName("price")
+ val price: String = "",
+ @SerialName("dailyPrice")
+ val dailyPrice: String = "",
+ @SerialName("weeklyPrice")
+ val weeklyPrice: String = "",
+ @SerialName("monthlyPrice")
+ val monthlyPrice: String = "",
+ @SerialName("yearlyPrice")
+ val yearlyPrice: String = "",
+ @SerialName("rawTrialPeriodPrice")
+ val rawTrialPeriodPrice: Double = 0.0,
+ @SerialName("trialPeriodPrice")
+ val trialPeriodPrice: String = "",
+ @SerialName("trialPeriodDailyPrice")
+ val trialPeriodDailyPrice: String = "",
+ @SerialName("trialPeriodWeeklyPrice")
+ val trialPeriodWeeklyPrice: String = "",
+ @SerialName("trialPeriodMonthlyPrice")
+ val trialPeriodMonthlyPrice: String = "",
+ @SerialName("trialPeriodYearlyPrice")
+ val trialPeriodYearlyPrice: String = "",
+ @SerialName("trialPeriodDays")
+ val trialPeriodDays: Int = 0,
+ @SerialName("trialPeriodWeeks")
+ val trialPeriodWeeks: Int = 0,
+ @SerialName("trialPeriodMonths")
+ val trialPeriodMonths: Int = 0,
+ @SerialName("trialPeriodYears")
+ val trialPeriodYears: Int = 0,
+ @SerialName("trialPeriodText")
+ val trialPeriodText: String = "",
+ @SerialName("trialPeriodEndDate")
+ val trialPeriodEndDate: String = "",
+ )
+ }
}
@Serializable
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
index af5f9602e..c8332e5d9 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt
@@ -19,6 +19,7 @@ import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
+import android.os.SystemClock
import android.os.Looper
import android.view.View
import android.view.ViewGroup
@@ -63,10 +64,11 @@ import com.superwall.sdk.utilities.withErrorTracking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import java.lang.ref.WeakReference
import java.util.UUID
import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
+import kotlin.coroutines.resumeWithException
class SuperwallPaywallActivity : AppCompatActivity() {
companion object {
@@ -827,6 +829,7 @@ class SuperwallPaywallActivity : AppCompatActivity() {
}
override fun onDestroy() {
+ notificationPermissionCallback?.onPermissionResult(false)
super.onDestroy()
val content = contentView as? ViewGroup?
@@ -903,30 +906,76 @@ class SuperwallPaywallActivity : AppCompatActivity() {
notifications: List,
factory: DeviceHelperFactory,
cancelExisting: Boolean = false,
- ) = suspendCoroutine { continuation ->
+ ) = attemptToScheduleNotifications(notifications, factory, cancelExisting, applySandboxScaling = true)
+
+ internal suspend fun attemptToScheduleNotifications(
+ notifications: List,
+ factory: DeviceHelperFactory,
+ cancelExisting: Boolean,
+ applySandboxScaling: Boolean,
+ ) = suspendCancellableCoroutine { continuation ->
if (notifications.isEmpty()) {
continuation.resume(Unit) // Resume immediately as there's nothing to schedule
- return@suspendCoroutine
+ return@suspendCancellableCoroutine
}
createNotificationChannel()
-
- notificationPermissionCallback =
+ val permissionRequestedAt = SystemClock.elapsedRealtime()
+ // A replacement request must release the previous waiter, too.
+ notificationPermissionCallback?.onPermissionResult(false)
+ val callback =
object : NotificationPermissionCallback {
override fun onPermissionResult(granted: Boolean) {
- if (granted) {
- NotificationScheduler.scheduleNotifications(
- notifications = notifications,
- factory = factory,
- context = this@SuperwallPaywallActivity,
- cancelExisting = cancelExisting,
- )
+ if (notificationPermissionCallback === this) notificationPermissionCallback = null
+ try {
+ if (granted) {
+ scheduleGrantedNotifications(
+ notifications,
+ factory,
+ cancelExisting,
+ applySandboxScaling,
+ permissionRequestedAt,
+ )
+ }
+ } catch (e: Exception) {
+ if (continuation.isActive) continuation.resumeWithException(e)
+ return
}
- continuation.resume(Unit) // Resume coroutine after processing
+ if (continuation.isActive) continuation.resume(Unit)
}
}
+ notificationPermissionCallback = callback
+ try {
+ checkAndRequestNotificationPermissions(this, callback)
+ } catch (e: Exception) {
+ if (notificationPermissionCallback === callback) notificationPermissionCallback = null
+ if (continuation.isActive) continuation.resumeWithException(e)
+ }
+ }
- checkAndRequestNotificationPermissions(this, notificationPermissionCallback!!)
+ private fun scheduleGrantedNotifications(
+ notifications: List,
+ factory: DeviceHelperFactory,
+ cancelExisting: Boolean,
+ applySandboxScaling: Boolean,
+ permissionRequestedAt: Long,
+ ) {
+ // Web delays are anchored to checkout, so permission wait must not shift them.
+ val readyNotifications =
+ if (applySandboxScaling) {
+ notifications
+ } else {
+ val elapsed = SystemClock.elapsedRealtime() - permissionRequestedAt
+ notifications.mapNotNull { it.copy(delay = it.delay - elapsed).takeIf { reminder -> reminder.delay > 0 } }
+ }
+ if (readyNotifications.isEmpty()) return
+ NotificationScheduler.scheduleNotifications(
+ notifications = readyNotifications,
+ factory = factory,
+ context = this,
+ cancelExisting = cancelExisting,
+ applySandboxScaling = applySandboxScaling,
+ )
}
private fun createNotificationChannel() {
diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt
index d7f913eed..2b3287dd2 100644
--- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt
+++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt
@@ -322,6 +322,12 @@ internal object PurchasingProductdIds : Storable> {
get() = SetSerializer(String.serializer())
}
+internal object TrackedWebTrialCodes : Storable> {
+ override val key = "store.trackedWebTrialCodes"
+ override val directory = SearchPathDirectory.APP_SPECIFIC_DOCUMENTS
+ override val serializer = SetSerializer(String.serializer())
+}
+
internal object LatestRedemptionResponse : Storable {
override val key: String
get() = "store.latestRedemptionResponse"
diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt
index 33a78a9d6..5e9b07702 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/notifications/NotificationScheduler.kt
@@ -45,6 +45,7 @@ internal class NotificationScheduler {
factory: DeviceHelperFactory,
context: Context,
cancelExisting: Boolean = false,
+ applySandboxScaling: Boolean = true,
) {
val workManager = WorkManager.getInstance(context)
IOScope().launch {
@@ -67,7 +68,7 @@ internal class NotificationScheduler {
var delay = notification.delay // delay in milliseconds
val isSandbox = factory.makeIsSandbox()
- if (isSandbox) {
+ if (isSandbox && applySandboxScaling) {
delay = delay / 24 / 60
}
diff --git a/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt
new file mode 100644
index 000000000..6fde5298b
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/web/RedemptionStoreProduct.kt
@@ -0,0 +1,86 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import com.superwall.sdk.store.abstractions.product.StoreProductType
+import com.superwall.sdk.store.abstractions.product.SubscriptionPeriod
+import org.threeten.bp.Instant
+import org.threeten.bp.LocalDate
+import org.threeten.bp.ZoneOffset
+import org.threeten.bp.format.DateTimeParseException
+import java.math.BigDecimal
+import java.util.Date
+
+/** Uses the checkout snapshot for trial analytics, including its original prices and end date. */
+internal class RedemptionStoreProduct(
+ private val product: PaywallProduct,
+) : StoreProductType {
+ override val fullIdentifier = product.identifier
+ override val productIdentifier = product.identifier
+ override val price = BigDecimal.valueOf(product.rawPrice)
+ override val localizedPrice = product.price
+ override val localizedSubscriptionPeriod = product.localizedPeriod
+ override val period = product.period
+ override val periodly = product.periodly
+ override val periodDays = product.periodDays
+ override val periodWeeks = product.periodWeeks
+ override val periodMonths = product.periodMonths
+ override val periodYears = product.periodYears
+ override val periodDaysString = periodDays.toString()
+ override val periodWeeksString = periodWeeks.toString()
+ override val periodMonthsString = periodMonths.toString()
+ override val periodYearsString = periodYears.toString()
+ override val dailyPrice = product.dailyPrice
+ override val weeklyPrice = product.weeklyPrice
+ override val monthlyPrice = product.monthlyPrice
+ override val yearlyPrice = product.yearlyPrice
+ override val hasFreeTrial = product.trialPeriodDays > 0
+ override val localizedTrialPeriodPrice = product.trialPeriodPrice
+ override val trialPeriodPrice = BigDecimal.valueOf(product.rawTrialPeriodPrice)
+ override val trialPeriodEndDateString = product.trialPeriodEndDate
+ override val trialPeriodEndDate: Date? by lazy {
+ // Checkout snapshots may contain either an ISO timestamp or a calendar date.
+ try {
+ Date(Instant.parse(product.trialPeriodEndDate).toEpochMilli())
+ } catch (_: DateTimeParseException) {
+ try {
+ Date(
+ LocalDate
+ .parse(product.trialPeriodEndDate)
+ .atStartOfDay()
+ .toInstant(ZoneOffset.UTC)
+ .toEpochMilli(),
+ )
+ } catch (_: DateTimeParseException) {
+ null
+ }
+ }
+ }
+ override val trialPeriodDays = product.trialPeriodDays
+ override val trialPeriodWeeks = product.trialPeriodWeeks
+ override val trialPeriodMonths = product.trialPeriodMonths
+ override val trialPeriodYears = product.trialPeriodYears
+ override val trialPeriodDaysString = trialPeriodDays.toString()
+ override val trialPeriodWeeksString = trialPeriodWeeks.toString()
+ override val trialPeriodMonthsString = trialPeriodMonths.toString()
+ override val trialPeriodYearsString = trialPeriodYears.toString()
+ override val trialPeriodText = product.trialPeriodText
+ override val locale = product.locale
+ override val languageCode = product.languageCode
+ override val currencyCode = product.currencyCode
+ override val currencySymbol = product.currencySymbol
+ override val regionCode: String? = null
+ override val subscriptionPeriod =
+ product.periodDays.takeIf { it > 0 }?.let { SubscriptionPeriod(it, SubscriptionPeriod.Unit.day).normalized() }
+ override val productType = if (subscriptionPeriod != null) "subs" else "inapp"
+
+ override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String =
+ when (unit) {
+ SubscriptionPeriod.Unit.day -> product.trialPeriodDailyPrice
+ SubscriptionPeriod.Unit.week -> product.trialPeriodWeeklyPrice
+ SubscriptionPeriod.Unit.month -> product.trialPeriodMonthlyPrice
+ SubscriptionPeriod.Unit.year -> product.trialPeriodYearlyPrice
+ }
+
+ override val attributes: Map
+ get() = super.attributes + ("periodAlt" to product.periodAlt)
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt
index b67751ef3..d9b5d6e1b 100644
--- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt
@@ -22,24 +22,35 @@ import com.superwall.sdk.models.internal.ErrorInfo
import com.superwall.sdk.models.internal.RedemptionOwnership
import com.superwall.sdk.models.internal.RedemptionOwnershipType
import com.superwall.sdk.models.internal.RedemptionResult
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
import com.superwall.sdk.models.internal.UserId
+import com.superwall.sdk.models.paywall.LocalNotification
+import com.superwall.sdk.models.paywall.LocalNotificationType
import com.superwall.sdk.network.Network
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.storage.LastWebEntitlementsFetchDate
import com.superwall.sdk.storage.LatestRedemptionResponse
import com.superwall.sdk.storage.LatestWebCustomerInfo
import com.superwall.sdk.storage.Storage
+import com.superwall.sdk.storage.TrackedWebTrialCodes
+import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.utilities.withErrorTracking
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
+internal const val WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS = 30_000L
+
@Suppress("EXPOSED_PARAMETER_TYPE")
class WebPaywallRedeemer(
private val context: Context,
@@ -90,10 +101,17 @@ class WebPaywallRedeemer(
fun closePaywallIfExists()
fun isPaymentSheetOpen(): Boolean
+
+ suspend fun scheduleTrialNotifications(notifications: List) {}
+
+ fun currentTimeMillis(): Long = System.currentTimeMillis()
}
private var pollingJob: Job? = null
private var redemptionJob: Job? = null
+ // Code redemptions launch independently on IOScope; hold this across track() so two
+ // overlapping same-code calls cannot both observe an empty set and emit twice.
+ private val trialTrackingMutex = Mutex()
private suspend fun track(event: Trackable) = factory.track(event)
@@ -223,6 +241,24 @@ class WebPaywallRedeemer(
redemption,
),
)
+ // Apply access before trial handling can wait for notification permission.
+ factory.internallySetSubscriptionStatus(
+ SubscriptionStatus.Active(
+ it.customerInfo
+ ?.entitlements
+ ?.filter { it.isActive }
+ ?.toSet()
+ .orEmpty() +
+ factory.getActiveDeviceEntitlements(),
+ ),
+ )
+ val codeResult =
+ if (redemption is RedeemType.Code) {
+ it.codes.firstOrNull { result -> result.code == redemption.code }
+ ?: RedemptionResult.Error(redemption.code, ErrorInfo("Redemption failed, code not returned"))
+ } else {
+ null
+ }
when (redemption) {
is RedeemType.Code -> {
Logger.debug(
@@ -238,22 +274,9 @@ class WebPaywallRedeemer(
),
)
- val result =
- if (it.codes.any { it.code == redemption.code }) {
- it.codes
- } else {
- listOf(
- RedemptionResult.Error(
- code =
- (redemption as? RedeemType.Code?)?.code
- ?: "",
- error = ErrorInfo("Redemption failed, code not returned"),
- ),
- )
- }
- val redemptionResultForCode =
- result.firstOrNull { it.code == redemption.code }
- if (redemptionResultForCode != null) {
+ if (codeResult != null) {
+ // Restoration can dismiss the paywall too, so finish trial work first.
+ handleTrialRedemption(codeResult)
if (factory.isPaywallVisible() && !factory.isPaymentSheetOpen()) {
if (it.customerInfo?.entitlements?.map { it.id }?.containsAll(
factory.currentPaywallEntitlements().map { it.id },
@@ -271,23 +294,9 @@ class WebPaywallRedeemer(
// NO-OP
}
}
- factory.internallySetSubscriptionStatus(
- SubscriptionStatus.Active(
- (
- it.customerInfo
- ?.entitlements
- ?.filter { it.isActive }
- ?.toSet() ?: emptySet()
- ) +
- factory.getActiveDeviceEntitlements(),
- ),
- )
- if (redemption is RedeemType.Code) {
+ if (codeResult != null) {
factory.closePaywallIfExists()
- val res = it.codes.first { it.code == redemption.code }
- factory.didRedeemLink(
- res,
- )
+ factory.didRedeemLink(codeResult)
}
// Notify the delegate that the redemption succeeded, unless the code has not been redeemed
@@ -329,6 +338,61 @@ class WebPaywallRedeemer(
startPolling()
}
+ private suspend fun handleTrialRedemption(result: RedemptionResult) {
+ val product = (result as? RedemptionResult.Success)?.redemptionInfo?.paywallInfo?.product ?: return
+ if (product.trialPeriodDays <= 0 || !factory.isPaywallVisible()) return
+ // Match iOS: eligibility and attribution use the active paywall's presentation snapshot;
+ // purchased product details come from the web checkout response.
+ val paywallInfo = factory.getPaywallInfo()
+ if (!paywallInfo.isFreeTrialAvailable) return
+
+ attemptTrialSideEffect("track web free trial start") {
+ trialTrackingMutex.withLock {
+ val trackedCodes = storage.read(TrackedWebTrialCodes).orEmpty()
+ if (result.code !in trackedCodes) {
+ track(InternalSuperwallEvent.FreeTrialStart(paywallInfo, StoreProduct(RedemptionStoreProduct(product))))
+ storage.write(TrackedWebTrialCodes, trackedCodes + result.code)
+ }
+ }
+ }
+ val reminders = trialReminders(paywallInfo, product)
+ if (reminders.isEmpty()) return
+ attemptTrialSideEffect("schedule web trial notifications") {
+ withTimeoutOrNull(WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS) {
+ factory.scheduleTrialNotifications(reminders)
+ }
+ }
+ }
+
+ private fun trialReminders(
+ paywallInfo: PaywallInfo,
+ product: PaywallProduct,
+ ): List =
+ paywallInfo.localNotifications.mapNotNull { notification ->
+ if (notification.type != LocalNotificationType.TrialStarted) return@mapNotNull null
+ webTrialReminderDelay(product, notification.delay, factory.currentTimeMillis())?.let { delay ->
+ notification.copy(id = "${paywallInfo.identifier}_${notification.type.raw}", delay = delay)
+ }
+ }
+
+ private suspend fun attemptTrialSideEffect(
+ description: String,
+ block: suspend () -> Unit,
+ ) {
+ try {
+ block()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.webEntitlements,
+ message = "Failed to $description",
+ error = e,
+ )
+ }
+ }
+
suspend fun checkForWebEntitlements(
userId: UserId?,
deviceId: DeviceVendorId,
diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebTrialReminder.kt b/superwall/src/main/java/com/superwall/sdk/web/WebTrialReminder.kt
new file mode 100644
index 000000000..c9a951bb0
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/web/WebTrialReminder.kt
@@ -0,0 +1,27 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import org.threeten.bp.DateTimeException
+import org.threeten.bp.Duration
+import org.threeten.bp.Instant
+import org.threeten.bp.OffsetDateTime
+
+/** A config delay is relative to checkout, whereas WorkManager needs a delay relative to redemption. */
+internal fun webTrialReminderDelay(
+ product: PaywallProduct,
+ configuredDelay: Long,
+ now: Long,
+): Long? {
+ if (product.trialPeriodDays <= 0 || configuredDelay <= 0) return null
+ // Display strings and calendar dates have no unambiguous instant. Never guess their timezone.
+ return try {
+ val end = OffsetDateTime.parse(product.trialPeriodEndDate).toInstant()
+ val target = end.minus(Duration.ofDays(product.trialPeriodDays.toLong())).plusMillis(configuredDelay)
+ val current = Instant.ofEpochMilli(now)
+ if (target <= current || target >= end) null else Duration.between(current, target).toMillis()
+ } catch (_: DateTimeException) {
+ null
+ } catch (_: ArithmeticException) {
+ null
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt
new file mode 100644
index 000000000..1f39c2c69
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt
@@ -0,0 +1,211 @@
+package com.superwall.sdk.paywall.view
+
+import android.Manifest
+import android.app.Application
+import android.content.pm.PackageManager
+import androidx.test.core.app.ApplicationProvider
+import com.superwall.sdk.dependencies.DeviceHelperFactory
+import com.superwall.sdk.models.paywall.LocalNotification
+import com.superwall.sdk.models.paywall.LocalNotificationType
+import com.superwall.sdk.store.transactions.notifications.NotificationScheduler
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowSystemClock
+import java.time.Duration
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], manifest = Config.NONE)
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+class TrialNotificationPermissionTest {
+ private val notifications =
+ listOf(LocalNotification("trial", LocalNotificationType.TrialStarted, "Trial ending", body = "Reminder", delay = 86_400_000L))
+ private val factory = mockk()
+
+ @Before
+ fun setup() {
+ mockkObject(NotificationScheduler.Companion)
+ every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } just Runs
+ every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) } just Runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(NotificationScheduler.Companion)
+ }
+
+ @Test
+ fun `permission wait is subtracted from absolute web reminder delay`() =
+ runTest {
+ val activity = deniedActivity()
+ val job = launchWait(activity, applySandboxScaling = false)
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(10))
+ activity.deliverPermission(granted = true)
+ job.join()
+ verify(exactly = 1) {
+ NotificationScheduler.scheduleNotifications(
+ listOf(notifications.single().copy(delay = 86_390_000L)),
+ factory,
+ activity,
+ false,
+ false,
+ )
+ }
+ }
+
+ @Test
+ fun `reminder that expires during permission wait is skipped`() =
+ runTest {
+ val activity = deniedActivity()
+ val job =
+ launch {
+ activity.attemptToScheduleNotifications(
+ listOf(notifications.single().copy(delay = 5_000L)),
+ factory,
+ false,
+ false,
+ )
+ }
+ runCurrent()
+ ShadowSystemClock.advanceBy(Duration.ofSeconds(10))
+ activity.deliverPermission(granted = true)
+ job.join()
+ verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `cancelled permission wait still schedules a late grant once`() =
+ runTest {
+ val activity = deniedActivity()
+ val job = launchWait(activity)
+ job.cancelAndJoin()
+ repeat(2) { activity.deliverPermission(granted = true) }
+ verify(exactly = 1) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `destroyed activity releases the permission waiter`() =
+ runTest {
+ val controller = deniedActivityController()
+ val activity = controller.get()
+ val job = launchWait(activity)
+ controller.destroy()
+ runCurrent()
+ assertTrue(job.isCompleted)
+ activity.deliverPermission(granted = true)
+ verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `web reminders bypass native sandbox delay scaling`() =
+ runTest {
+ val activity = grantedActivity()
+ activity.attemptToScheduleNotifications(notifications, factory, cancelExisting = false, applySandboxScaling = false)
+ verify(exactly = 1) { NotificationScheduler.scheduleNotifications(notifications, factory, activity, false, false) }
+ }
+
+ @Test
+ fun `granted notification permission schedules the reminders`() =
+ runTest {
+ val activity = grantedActivity()
+ activity.attemptToScheduleNotifications(notifications, factory)
+ verify(exactly = 1) { NotificationScheduler.scheduleNotifications(notifications, factory, activity, false) }
+ }
+
+ @Test
+ fun `denied permission completes the attempt without scheduling`() =
+ runTest {
+ val activity = deniedActivity()
+ val job = launchWait(activity)
+ assertFalse(job.isCompleted)
+ activity.deliverPermission(granted = false)
+ runCurrent()
+ assertTrue(job.isCompleted)
+ verify(exactly = 0) { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `scheduling failure after permission grant reaches the waiting caller`() =
+ runTest {
+ val activity = deniedActivity()
+ val failure = IllegalStateException("WorkManager unavailable")
+ every { NotificationScheduler.scheduleNotifications(any(), any(), any(), any()) } throws failure
+ var received: Exception? = null
+ val job =
+ launch {
+ try {
+ activity.attemptToScheduleNotifications(notifications, factory)
+ } catch (e: Exception) {
+ received = e
+ }
+ }
+ runCurrent()
+ activity.deliverPermission(granted = true)
+ runCurrent()
+ assertTrue(job.isCompleted)
+ assertTrue(received is IllegalStateException)
+ assertEquals(failure.message, received?.message)
+ }
+
+ private fun app() = ApplicationProvider.getApplicationContext()
+
+ private fun grantedActivity(): SuperwallPaywallActivity {
+ shadowOf(app()).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
+ return Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get()
+ }
+
+ private fun deniedActivity(): SuperwallPaywallActivity {
+ shadowOf(app()).denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
+ return Robolectric.buildActivity(SuperwallPaywallActivity::class.java).get()
+ }
+
+ private fun deniedActivityController(): ActivityController {
+ shadowOf(app()).denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
+ val controller = Robolectric.buildActivity(SuperwallPaywallActivity::class.java)
+ controller.get().setTheme(androidx.appcompat.R.style.Theme_AppCompat)
+ return controller.create()
+ }
+
+ private fun TestScope.launchWait(
+ activity: SuperwallPaywallActivity,
+ applySandboxScaling: Boolean = true,
+ ): Job {
+ val job =
+ launch {
+ activity.attemptToScheduleNotifications(notifications, factory, false, applySandboxScaling)
+ }
+ runCurrent()
+ return job
+ }
+
+ private fun SuperwallPaywallActivity.deliverPermission(granted: Boolean) {
+ val request = shadowOf(this).lastRequestedPermission
+ onRequestPermissionsResult(
+ request.requestCode,
+ request.requestedPermissions,
+ intArrayOf(if (granted) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED),
+ )
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt
new file mode 100644
index 000000000..1ea77b638
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/web/RedemptionStoreProductTest.kt
@@ -0,0 +1,164 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import com.superwall.sdk.models.internal.WebRedemptionResponse
+import com.superwall.sdk.network.JsonFactory
+import com.superwall.sdk.storage.LatestRedemptionResponse
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import org.junit.Assert.*
+import org.junit.Test
+import java.math.BigDecimal
+import java.util.Date
+
+class RedemptionStoreProductTest {
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+ private val response = trialRedemptionFixture()
+ private val info = (response.codes.single() as RedemptionResult.Success).redemptionInfo
+ private val product = info.paywallInfo!!.product!!
+
+ @Test
+ fun `original Kotlin default constructor and copy bytecode signatures remain callable`() {
+ val type = RedemptionResult.PaywallInfo::class.java
+ val marker = Class.forName("kotlin.jvm.internal.DefaultConstructorMarker")
+ val constructor =
+ type.getConstructor(
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ Int::class.javaPrimitiveType,
+ marker,
+ )
+ val created = constructor.newInstance("paywall", "placement", emptyMap(), "variant", "experiment", null, 32, null)
+ assertNull(created.productIdentifier)
+ val oldCopy =
+ type.getMethod(
+ "copy",
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ )
+ val source = info.paywallInfo!!
+ val fullCopy =
+ oldCopy.invoke(
+ source,
+ source.identifier,
+ "changed",
+ source.placementParams,
+ source.variantId,
+ source.experimentId,
+ source.productIdentifier,
+ )
+ assertEquals(source.copy(placementName = "changed"), fullCopy)
+ val defaultCopy =
+ type.getMethod(
+ "copy\$default",
+ type,
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ Int::class.javaPrimitiveType,
+ Any::class.java,
+ )
+ val copied = defaultCopy.invoke(null, source, null, "changed", null, null, null, null, 61, null) as RedemptionResult.PaywallInfo
+ assertEquals("changed", copied.placementName)
+ assertEquals(product, copied.product)
+ assertEquals(source.identifier, copied.component1())
+ assertEquals(source.productIdentifier, copied.component6())
+ }
+
+ @Test
+ fun `existing six argument Java constructor remains available`() {
+ val constructor =
+ RedemptionResult.PaywallInfo::class.java.getConstructor(
+ String::class.java,
+ String::class.java,
+ Map::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ )
+ val legacy = constructor.newInstance("paywall", "placement", emptyMap(), "variant", "experiment", "product")
+ assertEquals("product", legacy.productIdentifier)
+ assertNull(legacy.product)
+ }
+
+ @Test
+ fun `all checkout product variables survive decoding and cache round trip`() {
+ val fixture = requireNotNull(javaClass.getResource("/web-redemption-trial.json")).readText()
+ val expected =
+ json
+ .parseToJsonElement(fixture)
+ .jsonObject["codes"]!!
+ .jsonArray
+ .single()
+ .jsonObject["redemptionInfo"]!!
+ .jsonObject["paywallInfo"]!!
+ .jsonObject["product"]
+ assertEquals(expected, json.encodeToJsonElement(PaywallProduct.serializer(), product))
+ val cacheJson = JsonFactory.JSON
+ val cached = cacheJson.encodeToString(LatestRedemptionResponse.serializer, response)
+ val restored = cacheJson.decodeFromString(LatestRedemptionResponse.serializer, cached)
+ assertEquals(response.codes, restored.codes)
+ assertEquals(response.customerInfo, restored.customerInfo)
+ }
+
+ @Test
+ fun `legacy and null product responses still decode`() {
+ val encoded = json.encodeToJsonElement(RedemptionResult.PaywallInfo.serializer(), info.paywallInfo!!).jsonObject
+ for (legacy in listOf(JsonObject(encoded - "product"), JsonObject(encoded + ("product" to JsonNull)))) {
+ val decoded = json.decodeFromJsonElement(RedemptionResult.PaywallInfo.serializer(), legacy)
+ assertNull(decoded.product)
+ assertEquals("test_product", decoded.productIdentifier)
+ }
+ }
+
+ @Test
+ fun `product is retained without the legacy identifier`() {
+ val encoded = json.encodeToJsonElement(RedemptionResult.PaywallInfo.serializer(), info.paywallInfo!!).jsonObject
+ val decoded = json.decodeFromJsonElement(RedemptionResult.PaywallInfo.serializer(), JsonObject(encoded - "productIdentifier"))
+ assertNull(decoded.productIdentifier)
+ assertEquals(product, decoded.product)
+ }
+
+ @Test
+ fun `adapter preserves prices periods and trial end rather than recalculating them`() {
+ val adapted = RedemptionStoreProduct(product)
+ assertEquals(BigDecimal("9.99"), adapted.price)
+ assertEquals("$0.00", adapted.localizedTrialPeriodPrice)
+ assertEquals("7-day free trial", adapted.trialPeriodText)
+ assertEquals("mo", adapted.attributes["periodAlt"])
+ assertEquals("month", adapted.attributes["localizedPeriod"])
+ assertEquals("2026-09-14T12:30:00.000Z", adapted.attributes["trialPeriodEndDate"])
+ assertEquals(Date(1789389000000L), adapted.trialPeriodEndDate)
+ assertEquals("$0.00", adapted.attributes["trialPeriodWeeklyPrice"])
+ }
+
+ @Test
+ fun `date only and invalid trial dates do not affect original callback text`() {
+ val dateOnly = RedemptionStoreProduct(product.copy(trialPeriodEndDate = "2026-09-14"))
+ assertEquals(Date(1789344000000L), dateOnly.trialPeriodEndDate)
+ for (value in listOf("", "not a date")) {
+ val adapted = RedemptionStoreProduct(product.copy(trialPeriodEndDate = value))
+ assertNull(adapted.trialPeriodEndDate)
+ assertEquals(value, adapted.trialPeriodEndDateString)
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt
new file mode 100644
index 000000000..b914d1fca
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt
@@ -0,0 +1,373 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
+import com.superwall.sdk.analytics.internal.trackable.Trackable
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.IOScope
+import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.models.internal.DeviceVendorId
+import com.superwall.sdk.models.internal.ErrorInfo
+import com.superwall.sdk.models.internal.RedemptionResult
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import com.superwall.sdk.models.internal.UserId
+import com.superwall.sdk.models.internal.VendorId
+import com.superwall.sdk.models.internal.WebRedemptionResponse
+import com.superwall.sdk.models.paywall.LocalNotification
+import com.superwall.sdk.models.paywall.LocalNotificationType
+import com.superwall.sdk.models.triggers.Experiment
+import com.superwall.sdk.network.Network
+import com.superwall.sdk.paywall.presentation.PaywallInfo
+import com.superwall.sdk.storage.LatestRedemptionResponse
+import com.superwall.sdk.storage.Storage
+import com.superwall.sdk.storage.TrackedWebTrialCodes
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import org.junit.Assert.*
+import org.junit.Test
+
+internal fun trialRedemptionFixture(): WebRedemptionResponse =
+ Json { ignoreUnknownKeys = true }.decodeFromString(
+ requireNotNull(WebRedemptionTrialTest::class.java.getResource("/web-redemption-trial.json")).readText(),
+ )
+
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+class WebRedemptionTrialTest {
+ private var response = trialRedemptionFixture()
+ private val factory = mockk(relaxed = true)
+ private val network = mockk()
+ private val storage = mockk(relaxed = true)
+ private val events = mutableListOf()
+ private val order = mutableListOf()
+ private val reminder =
+ LocalNotification("reminder", LocalNotificationType.TrialStarted, "Trial ending", body = "Reminder", delay = 86_400_000L)
+ private var paywallInfo =
+ PaywallInfo.empty().copy(
+ identifier = "active_paywall",
+ experiment =
+ Experiment(
+ "active_experiment",
+ "group",
+ Experiment.Variant("active_variant", Experiment.Variant.VariantType.TREATMENT, "active_paywall"),
+ ),
+ isFreeTrialAvailable = true,
+ localNotifications = listOf(reminder),
+ )
+ private var trackedCodes = emptySet()
+ private var visible = true
+ private val result get() = response.codes.single() as RedemptionResult.Success
+
+ init {
+ every { factory.isWebToAppEnabled() } returns false
+ every { factory.getUserId() } returns UserId("appUserId")
+ every { factory.getDeviceId() } returns DeviceVendorId(VendorId("test-device"))
+ every { factory.getAliasId() } returns null
+ every { factory.getActiveDeviceEntitlements() } returns emptySet()
+ every { factory.currentPaywallEntitlements() } answers { response.customerInfo!!.entitlements.toSet() }
+ every { factory.maxAge() } returns 60_000L
+ every { factory.currentTimeMillis() } returns 1788784200000L // Checkout: September 7, 12:30 UTC
+ every { storage.read(TrackedWebTrialCodes) } answers { trackedCodes }
+ every { storage.write(TrackedWebTrialCodes, any()) } answers { trackedCodes = secondArg() }
+ every { factory.getIntegrationProps() } returns emptyMap()
+ every { factory.getExternalAccountId() } returns ""
+ coEvery { factory.receipts() } returns emptyList()
+ coEvery { factory.isPaywallVisible() } answers { visible }
+ every { factory.isPaymentSheetOpen() } returns false
+ every { factory.getPaywallInfo() } answers { paywallInfo }
+ every { factory.internallySetSubscriptionStatus(any()) } answers { order += "access" }
+ coEvery { factory.track(any()) } coAnswers {
+ events += firstArg()
+ if (firstArg() is InternalSuperwallEvent.FreeTrialStart) order += "trial"
+ }
+ coEvery { factory.scheduleTrialNotifications(any()) } coAnswers { order += "schedule" }
+ coEvery { factory.triggerRestoreInPaywall() } coAnswers { order += "restore" }
+ every { factory.closePaywallIfExists() } answers { order += "close" }
+ every { factory.didRedeemLink(any()) } answers { order += "callback" }
+ every { storage.read(LatestRedemptionResponse) } returns null
+ coEvery { network.redeemToken(any(), any(), any(), any(), any(), any(), any()) } coAnswers { Either.Success(response) }
+ coEvery { network.webEntitlementsByUserId(any(), any()) } coAnswers { awaitCancellation() }
+ }
+
+ private suspend fun TestScope.redeem(type: WebPaywallRedeemer.RedeemType = WebPaywallRedeemer.RedeemType.Code("TESTCODE")) {
+ val scope = IOScope(StandardTestDispatcher(testScheduler))
+ try {
+ WebPaywallRedeemer(mockk(), scope, mockk(), network, storage, mockk(relaxed = true), factory).redeem(type)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ private fun changeProduct(transform: (PaywallProduct) -> PaywallProduct?) {
+ val info = result.redemptionInfo
+ response =
+ WebRedemptionResponse(
+ customerInfo = response.customerInfo,
+ codes =
+ listOf(
+ result.copy(
+ redemptionInfo =
+ info.copy(
+ paywallInfo = info.paywallInfo!!.copy(product = transform(info.paywallInfo.product!!)),
+ ),
+ ),
+ ),
+ )
+ }
+
+ private fun assertNoTrialSideEffects() {
+ assertTrue(events.none { it is InternalSuperwallEvent.FreeTrialStart })
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ }
+
+ @Test
+ fun `missing permission result cannot strand successful redemption`() =
+ runTest {
+ coEvery { factory.scheduleTrialNotifications(any()) } coAnswers { awaitCancellation() }
+ val job = launch { redeem() }
+ runCurrent()
+ advanceTimeBy(WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS)
+ runCurrent()
+ assertTrue(job.isCompleted)
+ verify(exactly = 1) { factory.didRedeemLink(result) }
+ verify(exactly = 1) { factory.closePaywallIfExists() }
+ }
+
+ @Test
+ fun `repeated success after recreating redeemer only tracks one trial`() =
+ runTest {
+ redeem()
+ // The new redeemer reads the persisted marker, as it would after an app restart.
+ redeem()
+ assertEquals(setOf("TESTCODE"), trackedCodes)
+ assertEquals(1, events.filterIsInstance().size)
+ verify(exactly = 2) { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `overlapping same-code redemptions emit freeTrial_start once`() =
+ runTest {
+ val trackingStarted = CompletableDeferred()
+ val releaseTracking = CompletableDeferred()
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } coAnswers {
+ if (!trackingStarted.isCompleted) trackingStarted.complete(Unit)
+ releaseTracking.await()
+ events += firstArg()
+ }
+ val scope = IOScope(StandardTestDispatcher(testScheduler))
+ val redeemer = WebPaywallRedeemer(mockk(), scope, mockk(), network, storage, mockk(relaxed = true), factory)
+ try {
+ val first = launch { redeemer.redeem(WebPaywallRedeemer.RedeemType.Code("TESTCODE")) }
+ val second = launch { redeemer.redeem(WebPaywallRedeemer.RedeemType.Code("TESTCODE")) }
+ trackingStarted.await()
+ runCurrent()
+ releaseTracking.complete(Unit)
+ first.join()
+ second.join()
+ } finally {
+ scope.cancel()
+ }
+ assertEquals(1, events.filterIsInstance().size)
+ assertEquals(setOf("TESTCODE"), trackedCodes)
+ }
+
+ @Test
+ fun `failed redemption can subsequently start a trial`() =
+ runTest {
+ val success = response
+ response =
+ WebRedemptionResponse(
+ codes = listOf(RedemptionResult.Error("TESTCODE", ErrorInfo("retry"))),
+ customerInfo = success.customerInfo,
+ )
+ every { storage.read(LatestRedemptionResponse) } returns response
+ redeem()
+ assertTrue(trackedCodes.isEmpty())
+ response = success
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ }
+
+ @Test
+ fun `tracking failure does not mark the trial as emitted`() =
+ runTest {
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } throws IllegalStateException("retry")
+ redeem()
+ assertTrue(trackedCodes.isEmpty())
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } coAnswers { events += firstArg() }
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ }
+
+ @Test
+ fun `late redemption does not schedule an already missed reminder`() =
+ runTest {
+ every { factory.currentTimeMillis() } returns 1788957000000L // Two days after checkout; reminder was due after one.
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ verify { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `display only trial date still delivers callback and analytics`() =
+ runTest {
+ changeProduct { it.copy(trialPeriodEndDate = "September 14, 2026") }
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ verify { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `eligible redemption exposes product and tracks original trial data before either dismissal`() =
+ runTest {
+ redeem()
+ assertEquals(listOf("access", "trial", "schedule", "restore", "close", "callback"), order)
+ verify(exactly = 1) { factory.didRedeemLink(result) }
+ verify { factory.internallySetSubscriptionStatus(SubscriptionStatus.Active(response.customerInfo!!.entitlements.toSet())) }
+ coVerify(exactly = 1) {
+ factory.scheduleTrialNotifications(listOf(reminder.copy(id = "active_paywall_TRIAL_STARTED")))
+ }
+ val event = events.filterIsInstance().single()
+ assertEquals("freeTrial_start", event.rawName)
+ assertEquals("test_product", event.product.fullIdentifier)
+ assertEquals(7, event.product.trialPeriodDays)
+ assertEquals("2026-09-14T12:30:00.000Z", event.product.trialPeriodEndDateString)
+ val params = event.getSuperwallParameters()
+ assertEquals("test_product", params["product_id"])
+ assertEquals("active_paywall", params["paywall_identifier"])
+ assertEquals("active_experiment", params["experiment_id"])
+ assertEquals("active_variant", params["variant_id"])
+ assertEquals("7", params["product_trial_period_days"])
+ assertEquals("$0.00", params["product_trial_period_price"])
+ }
+
+ @Test
+ fun `legacy response without product still unlocks and calls delegate`() =
+ runTest {
+ changeProduct { null }
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(result) }
+ verify { factory.internallySetSubscriptionStatus(SubscriptionStatus.Active(response.customerInfo!!.entitlements.toSet())) }
+ }
+
+ @Test
+ fun `zero trial days skip trial side effects`() =
+ runTest {
+ changeProduct { it.copy(trialPeriodDays = 0) }
+ redeem()
+ assertNoTrialSideEffects()
+ }
+
+ @Test
+ fun `ineligible paywall skips trial side effects`() =
+ runTest {
+ paywallInfo = paywallInfo.copy(isFreeTrialAvailable = false)
+ redeem()
+ assertNoTrialSideEffects()
+ }
+
+ @Test
+ fun `no active paywall still delivers the full product`() =
+ runTest {
+ visible = false
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(result) }
+ assertEquals(
+ 7,
+ result.redemptionInfo.paywallInfo!!
+ .product!!
+ .trialPeriodDays,
+ )
+ }
+
+ @Test
+ fun `no trial reminders still tracks trial start`() =
+ runTest {
+ paywallInfo = paywallInfo.copy(localNotifications = listOf(reminder.copy(type = LocalNotificationType.Unsupported)))
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ coVerify(exactly = 0) { factory.scheduleTrialNotifications(any()) }
+ }
+
+ @Test
+ fun `background redemption refreshes never repeat trial side effects`() =
+ runTest {
+ redeem(WebPaywallRedeemer.RedeemType.Existing)
+ redeem(WebPaywallRedeemer.RedeemType.IntegrationAttributes)
+ assertNoTrialSideEffects()
+ verify(exactly = 0) { factory.didRedeemLink(any()) }
+ }
+
+ @Test
+ fun `failed code skips side effects even if another code has a trial`() =
+ runTest {
+ response =
+ WebRedemptionResponse(
+ customerInfo = response.customerInfo,
+ codes = listOf(result.copy(code = "OTHER"), RedemptionResult.Error("TESTCODE", ErrorInfo("failed"))),
+ )
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(response.codes.last()) }
+ }
+
+ @Test
+ fun `missing requested code returns error without using another products trial`() =
+ runTest {
+ response = WebRedemptionResponse(customerInfo = response.customerInfo, codes = listOf(result.copy(code = "OTHER")))
+ redeem()
+ assertNoTrialSideEffects()
+ verify { factory.didRedeemLink(match { it is RedemptionResult.Error && it.code == "TESTCODE" }) }
+ }
+
+ @Test
+ fun `scheduling failure preserves trial event access and callback`() =
+ runTest {
+ coEvery { factory.scheduleTrialNotifications(any()) } throws IllegalStateException("scheduler unavailable")
+ redeem()
+ assertEquals(1, events.filterIsInstance().size)
+ verify { factory.didRedeemLink(result) }
+ assertTrue(order.indexOf("access") < order.indexOf("callback"))
+ }
+
+ @Test
+ fun `tracking failure still schedules reminders and delivers callback`() =
+ runTest {
+ coEvery { factory.track(match { it is InternalSuperwallEvent.FreeTrialStart }) } throws
+ IllegalStateException("tracking unavailable")
+ redeem()
+ coVerify(exactly = 1) { factory.scheduleTrialNotifications(any()) }
+ verify { factory.didRedeemLink(result) }
+ }
+
+ @Test
+ fun `permission wait grants access immediately and defers dismissal until resolved`() =
+ runTest {
+ val permissionResult = CompletableDeferred()
+ coEvery { factory.scheduleTrialNotifications(any()) } coAnswers { permissionResult.await() }
+ val job = launch { redeem() }
+ runCurrent()
+ verify { factory.internallySetSubscriptionStatus(SubscriptionStatus.Active(response.customerInfo!!.entitlements.toSet())) }
+ coVerify(exactly = 0) { factory.triggerRestoreInPaywall() }
+ verify(exactly = 0) { factory.closePaywallIfExists() }
+ permissionResult.complete(Unit)
+ job.join()
+ verify(exactly = 1) { factory.didRedeemLink(result) }
+ verify(exactly = 1) { factory.closePaywallIfExists() }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebTrialReminderTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebTrialReminderTest.kt
new file mode 100644
index 000000000..f4da7c94f
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/web/WebTrialReminderTest.kt
@@ -0,0 +1,41 @@
+package com.superwall.sdk.web
+
+import com.superwall.sdk.models.internal.RedemptionResult.PaywallInfo.PaywallProduct
+import org.junit.Assert.*
+import org.junit.Test
+
+class WebTrialReminderTest {
+ private val checkout = 1788784200000L
+ private val day = 86_400_000L
+ private val product = PaywallProduct("web", trialPeriodDays = 7, trialPeriodEndDate = "2026-09-14T12:30:00Z")
+
+ @Test
+ fun `late redemption subtracts elapsed time from reminder delay`() {
+ assertEquals(5 * day, webTrialReminderDelay(product, 6 * day, checkout + day))
+ assertEquals(6 * day, webTrialReminderDelay(product, 6 * day, checkout))
+ }
+
+ @Test
+ fun `offset timestamps refer to the same instant`() {
+ assertEquals(
+ 5 * day,
+ webTrialReminderDelay(product.copy(trialPeriodEndDate = "2026-09-14T14:30:00+02:00"), 6 * day, checkout + day),
+ )
+ }
+
+ @Test
+ fun `past reminders and reminders at or after conversion are skipped`() {
+ assertNull(webTrialReminderDelay(product, day, checkout + 2 * day))
+ assertNull(webTrialReminderDelay(product, 7 * day, checkout))
+ assertNull(webTrialReminderDelay(product, 8 * day, checkout))
+ assertNull(webTrialReminderDelay(product, 6 * day, checkout + 8 * day))
+ }
+
+ @Test
+ fun `ambiguous invalid or overflowing dates are safe to skip`() {
+ for (end in listOf("", "2026-09-14", "September 14, 2026", "invalid", "+999999999-09-14T12:30:00Z")) {
+ assertNull(webTrialReminderDelay(product.copy(trialPeriodEndDate = end), day, checkout))
+ }
+ assertNull(webTrialReminderDelay(product, Long.MAX_VALUE, checkout))
+ }
+}
diff --git a/superwall/src/test/resources/web-redemption-trial.json b/superwall/src/test/resources/web-redemption-trial.json
new file mode 100644
index 000000000..6a3adfdf5
--- /dev/null
+++ b/superwall/src/test/resources/web-redemption-trial.json
@@ -0,0 +1,84 @@
+{
+ "codes": [
+ {
+ "status": "SUCCESS",
+ "code": "TESTCODE",
+ "redemptionInfo": {
+ "ownership": {
+ "type": "APP_USER",
+ "appUserId": "appUserId"
+ },
+ "purchaserInfo": {
+ "appUserId": "appUserId",
+ "storeIdentifiers": {
+ "store": "STRIPE",
+ "stripeCustomerId": "cus_123",
+ "stripeSubscriptionIds": [
+ "sub_123"
+ ]
+ }
+ },
+ "paywallInfo": {
+ "identifier": "test_paywall",
+ "placementName": "test_placement",
+ "placementParams": {},
+ "variantId": "variant_1",
+ "experimentId": "exp_1",
+ "product": {
+ "identifier": "test_product",
+ "languageCode": "en",
+ "locale": "en_US",
+ "currencyCode": "USD",
+ "currencySymbol": "$",
+ "period": "1 month",
+ "periodly": "monthly",
+ "localizedPeriod": "month",
+ "periodAlt": "mo",
+ "periodDays": 30,
+ "periodWeeks": 4,
+ "periodMonths": 1,
+ "periodYears": 0,
+ "rawPrice": 9.99,
+ "price": "$9.99",
+ "dailyPrice": "$0.33",
+ "weeklyPrice": "$2.50",
+ "monthlyPrice": "$9.99",
+ "yearlyPrice": "$119.88",
+ "rawTrialPeriodPrice": 0.0,
+ "trialPeriodPrice": "$0.00",
+ "trialPeriodDailyPrice": "$0.00",
+ "trialPeriodWeeklyPrice": "$0.00",
+ "trialPeriodMonthlyPrice": "$0.00",
+ "trialPeriodYearlyPrice": "$0.00",
+ "trialPeriodDays": 7,
+ "trialPeriodWeeks": 1,
+ "trialPeriodMonths": 0,
+ "trialPeriodYears": 0,
+ "trialPeriodText": "7-day free trial",
+ "trialPeriodEndDate": "2026-09-14T12:30:00.000Z"
+ },
+ "productIdentifier": "test_product"
+ },
+ "entitlements": [
+ {
+ "identifier": "premium",
+ "type": "SERVICE_LEVEL",
+ "isActive": true
+ }
+ ]
+ }
+ }
+ ],
+ "customerInfo": {
+ "subscriptions": [],
+ "nonSubscriptions": [],
+ "userId": "appUserId",
+ "entitlements": [
+ {
+ "identifier": "premium",
+ "type": "SERVICE_LEVEL",
+ "isActive": true
+ }
+ ]
+ }
+}