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