Fix web redemption product details and trial handling - #460
Conversation
There was a problem hiding this comment.
Important
The redemption critical path now awaits the POST_NOTIFICATIONS system dialog through a non-cancellable, unbounded suspendCoroutine. If that callback never arrives (activity recreated while the dialog is up), didRedeemLink() and closePaywallIfExists() never run. Worth bounding before merge.
Reviewed changes — full initial review of the single commit on codex/fix-web-redemption-trials, covering the redemption reordering, the new public PaywallProduct model, the StoreProductType adapter, and the three new test files.
- Full checkout product on
didRedeemLink—RedemptionResult.PaywallInfogains a nullableproduct: PaywallProduct?carrying a 28-field snapshot of the web-checkout product variables; the primary constructor becomes@JvmOverloads constructor(...)to keep the pre-existing 6-arg Java constructor callable. RedemptionStoreProductadapter — newinternal classmappingPaywallProduct→StoreProductType, deliberately preserving the checkout snapshot's prices/periods instead of recomputing, with ISO-instant-then-LocalDateparsing fortrialPeriodEndDateand aperiodAltoverride on top ofStoreProductType.attributes.freeTrial_start+ trial reminders for web redemptions — newWebPaywallRedeemer.handleTrialRedemptiontracks the event and awaits a newFactory.scheduleTrialNotifications(default no-op), implemented inDependencyContainerby hopping toDispatchers.Main.immediateand callingSuperwallPaywallActivity.attemptToScheduleNotifications. Side-effect failures are logged and swallowed byattemptTrialSideEffect.redeem()reordering and code-lookup fix —internallySetSubscriptionStatusmoves ahead of thewhen (redemption)block so access is granted before trial work can block, and the previousit.codes.first { … }(which threwNoSuchElementExceptionwhen the requested code was absent from the response) is replaced by a singlecodeResultwith anErrorfallback, reused for the restore branch, dismissal and delegate callback. Good fix.- Permission-callback failures no longer escape into the framework —
attemptToScheduleNotificationswrapsNotificationScheduler.scheduleNotificationsin try/catch andresumeWithExceptions instead of letting the throw propagate out ofonRequestPermissionsResult. - Tests —
WebRedemptionTrialTest(13 cases incl. a strictaccess → trial → schedule → restore → close → callbackordering pin),RedemptionStoreProductTest,TrialNotificationPermissionTest(Robolectric), plus aweb-redemption-trial.jsonfixture.
I confirmed the reordering of internallySetSubscriptionStatus is safe: nothing observing the subscription-status flow dismisses a presented paywall (store/Entitlements.kt:117-155, Superwall.kt:755-794; SubscriptionStatusDidChange.canImplicitlyTriggerPaywall is false, so Tracking.kt's dismiss branches are unreachable). I also refuted the concern that trialPeriodDays could be 0 for a week/month-only trial — RawStoreProduct.trialPeriodDays normalizes every unit into days.
⚠️ The new trial behavior has no test for the case that can strand the flow, and attribution is untested by construction
WebRedemptionTrialTest covers scheduling failure (coEvery { factory.scheduleTrialNotifications(any()) } throws …) and scheduling deferral-then-completion (CompletableDeferred completed inside the test), but never the case where the deferral simply never completes — which is the one that leaves the redemption stuck. A withTimeoutOrNull bound would be directly testable with runTest's virtual clock.
Separately, the fixture's checkout paywall is test_paywall (web-redemption-trial.json:22) while the mocked factory.getPaywallInfo() returns identifier = "active_paywall". That divergence looks deliberate, but no assertion pins which of the two ends up as paywall_identifier / experiment_id / variant_id on the emitted freeTrial_start, so the attribution decision is unpinned in either direction.
Technical details
# Missing coverage for the stranded-permission case and for trial-event attribution
## Affected sites
- `superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt:219-252` — the two `scheduleTrialNotifications` failure/deferral tests both terminate; neither models "callback never arrives".
- `superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt:121-139` — asserts `event.product.*` and three `getSuperwallParameters()` product keys, but no `paywall_identifier` / `experiment_id` / `variant_id` assertion.
- `superwall/src/test/resources/web-redemption-trial.json:22` (`test_paywall`) vs `WebRedemptionTrialTest.kt:55` (`active_paywall`).
## Required outcome
- A test that proves a never-resolving `scheduleTrialNotifications` still lets `didRedeemLink` and `closePaywallIfExists` run (this falls out naturally once the wait is bounded).
- An explicit assertion on which paywall identity the `freeTrial_start` event carries, so the choice between the on-screen paywall and `redemptionInfo.paywallInfo` is a decision the suite defends rather than an accident.
## Open questions for the human
- Should `freeTrial_start` from a web redemption be attributed to the paywall that was on screen at redemption time, or to the paywall recorded at web checkout (`redemptionInfo.paywallInfo.identifier` / `variantId` / `experimentId`)? In the usual web-to-app flow they coincide, but they need not.ℹ️ Nitpicks
RedemptionStoreProductTest.kt:75is namedproduct is retained without the deprecated identifier, butproductIdentifiercarries no@Deprecatedannotation and neither the KDoc nor theCHANGELOG.mdentry mentions a deprecation. Either annotate it or reword the test name.PaywallProduct's 28 fields carry no@SerialNameannotations while every sibling field inPaywallInfodoes. Functionally fine — the network decode path setsnamingStrategy = null(DependencyContainer.kt:378-383) andDirectSuccessSerializerbuilds its own naming-strategy-freeJson— but it's inconsistent with the surrounding style.WebRedemptionTrialTestcallsWebRedemptionResponse.copy(codes = …)in several places; becauseallCodesis@Transientwith acodes-derived default,copycarries the stale value forward rather than recomputing. Harmless here (storage.read(LatestRedemptionResponse)is stubbed tonull) but a trap for future tests.
Claude Opus | 𝕏
b7a9dcc to
fb7b278
Compare
There was a problem hiding this comment.
Important
The redemption critical path awaits attemptToScheduleNotifications, which is a non-cancellable suspendCoroutine. If the hosting activity is recreated while the POST_NOTIFICATIONS dialog is up, didRedeemLink(), closePaywallIfExists() and startPolling() never run. Worth noting that wrapping the call in withTimeoutOrNull does not fix this — the timeout cannot resume a raw continuation, so the timeout itself hangs too.
Reviewed changes — full re-review of codex/fix-web-redemption-trials. The previously reviewed commit 1e34b38 is no longer reachable in the repository (the branch was rewritten), so no incremental delta was computable and the whole diff was re-read from scratch.
- Full checkout product on
didRedeemLink—RedemptionResult.PaywallInfogains a nullableproduct: PaywallProduct?holding a 28-field snapshot of the web-checkout product variables; the primary constructor becomes@JvmOverloads constructor(...)to keep the pre-existing 6-arg Java constructor callable. RedemptionStoreProductadapter — newinternal classmappingPaywallProduct→StoreProductType, deliberately preserving the checkout snapshot's prices and periods instead of recomputing them, with ISO-instant-then-LocalDateparsing fortrialPeriodEndDateand aperiodAltoverride layered onStoreProductType.attributes.freeTrial_start+ trial reminders for web redemptions — newWebPaywallRedeemer.handleTrialRedemptiontracks the event and awaits a newFactory.scheduleTrialNotifications(default no-op), implemented inDependencyContainerby hopping toDispatchers.Main.immediateand callingSuperwallPaywallActivity.attemptToScheduleNotifications. Failures are logged and swallowed byattemptTrialSideEffect, which correctly rethrowsCancellationException.redeem()reordering and code-lookup fix —internallySetSubscriptionStatusmoves ahead of thewhen (redemption)block so access is granted before trial work can block, and the oldit.codes.first { … }(which threwNoSuchElementExceptionwhen the requested code was absent) is replaced by a singlecodeResultwith anErrorfallback, reused for the restore branch, dismissal and delegate callback. Good fix.- Permission-callback failures no longer escape into the framework —
attemptToScheduleNotificationswrapsNotificationScheduler.scheduleNotificationsin try/catch andresumeWithExceptions rather than letting the throw propagate out ofonRequestPermissionsResult. - Tests —
WebRedemptionTrialTest(13 cases including a strictaccess → trial → schedule → restore → close → callbackordering pin),RedemptionStoreProductTest,TrialNotificationPermissionTest(Robolectric), plus aweb-redemption-trial.jsonfixture.
I re-confirmed two things so they don't get re-litigated: moving internallySetSubscriptionStatus earlier is safe (nothing observing the subscription-status flow dismisses a presented paywall, and SubscriptionStatusDidChange.canImplicitlyTriggerPaywall is false), and trialPeriodDays <= 0 is a sound "no trial" gate because RawStoreProduct.trialPeriodDays normalizes every unit into days. I also verified that the notification id scheme here matches the native path at DependencyContainer.kt:672 exactly, so the two flows dedupe against each other rather than double-scheduling. Wire compatibility of the new product field checks out in both directions: RedemptionResult.Success routes through DirectSuccessSerializer's own namingStrategy = null Json, and every relevant instance sets ignoreUnknownKeys = true.
⚠️ Trial eligibility and attribution are taken from the on-screen native paywall, not from the web checkout
handleTrialRedemption uses factory.getPaywallInfo() for both the eligibility gate and the event's paywall identity, while the trial being reported is the one described by redemptionInfo.paywallInfo. isFreeTrialAvailable is computed by PaywallLogic from the native Play products' introductory-offer eligibility, so a user who already consumed their Play trial has it false — and a genuine web trial then emits no freeTrial_start at all, even though product.trialPeriodDays says a trial is running.
Technical details
# Web trial reporting is gated and attributed by native-paywall state
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt:333` — `if (!paywallInfo.isFreeTrialAvailable) return` gates a *web* trial on *native* product eligibility.
- `superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt:336` — `FreeTrialStart(paywallInfo, …)` attributes the event to the on-screen paywall, so `paywall_identifier` / `experiment_id` / `variant_id` come from there rather than from `redemptionInfo.paywallInfo`.
- `superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt:122-130` — origin of `isFreeTrialAvailable` (`hasFreeTrial` over the native products, subject to `isFreeTrialAvailableOverride`).
- `superwall/src/test/resources/web-redemption-trial.json:22` (`test_paywall`) vs `superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt:55` (`active_paywall`) — the divergence is set up but only the notification id (`active_paywall_TRIAL_STARTED`) is asserted; no assertion pins the event's paywall identity.
## Required outcome
- An explicit decision, defended by an assertion, on which paywall identity a web-redemption `freeTrial_start` carries.
- A gate whose meaning matches the thing being reported, so a real web trial is not dropped because the native products happen to be trial-ineligible.
## Open questions for the human
- Should `freeTrial_start` from a web redemption be attributed to the paywall on screen at redemption time, or to the paywall recorded at web checkout (`redemptionInfo.paywallInfo`)? They coincide in the usual flow but need not.
- Is the `isFreeTrialAvailable` check intended as "is this user trial-eligible" (in which case native eligibility is the wrong proxy for a web purchase) or as "does the on-screen paywall advertise a trial" (in which case it is deliberate and should be commented as such)?ℹ️ The one failure mode that strands the flow is untested, and the reminder delay is pinned as correct
WebRedemptionTrialTest covers scheduling failure and scheduling deferral-then-completion, but never the deferral that simply never completes — the case that actually strands the redemption. That gap is not incidental: with attemptToScheduleNotifications built on a raw suspendCoroutine, such a test would hang the suite outright rather than fail, which is itself the clearest demonstration of the inline finding. Separately, the scheduling assertion expects reminder.copy(id = …) with delay unchanged, so it locks in the un-adjusted delay described in the other inline comment.
Technical details
# Missing coverage for the stranded-permission case; delay pass-through asserted as correct
## Affected sites
- `superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt:219-252` — the failure and deferral tests both terminate; neither models "callback never arrives".
- `superwall/src/test/java/com/superwall/sdk/web/WebRedemptionTrialTest.kt:127-129` — `coVerify { factory.scheduleTrialNotifications(listOf(reminder.copy(id = "active_paywall_TRIAL_STARTED"))) }` asserts `delay` is passed through untouched, so it would keep passing with the mis-timing bug present.
## Required outcome
- A test proving that a `scheduleTrialNotifications` which never resolves still lets `didRedeemLink` and `closePaywallIfExists` run. This becomes writable with `runTest`'s virtual clock only once the underlying suspension is genuinely cancellable.
- Once the delay is derived from the trial end date, an assertion on the resulting `delay` rather than on the config value.ℹ️ Nitpicks
RedemptionStoreProductTest.kt:75is namedproduct is retained without the deprecated identifier, butproductIdentifiercarries no@Deprecatedannotation and neither the KDoc nor theCHANGELOG.mdentry mentions a deprecation. Either annotate it or reword the test name.WebRedemptionTrialTestcallsWebRedemptionResponse.copy(codes = …)in several places. BecauseallCodesis@Transientwith acodes-derived default,copycarries the stale value forward instead of recomputing it. Harmless here (storage.read(LatestRedemptionResponse)is stubbed tonull, soallCodesis never read back) but a trap for anyone extending these tests.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ All eight prior review threads are addressed and resolved. One informational note inline, plus a CI observation below.
Reviewed changes — the delta since the prior pullfrog review (fb7b278) is the single commit e7d9289, which reworks the three findings from that review plus the idempotency and ABI threads from the first one.
- Bounded and made cancellable the notification-permission wait —
attemptToScheduleNotificationsbecomessuspendCancellableCoroutinewithinvokeOnCancellation, releases a previous waiter before installing a new one, guards oncontinuation.isActive, and is released fromonDestroy; the redemption side wraps it inwithTimeoutOrNull(WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS = 30s). The public 3-arg overload is retained and delegates withapplySandboxScaling = true, so both pre-existing native callers (DependencyContainer.kt:685,Superwall.kt:1553) are unchanged apart from no longer leaking on activity destruction. - Anchored web trial reminders to the checkout end date — new
web/WebTrialReminder.ktback-derives trial start fromtrialPeriodEndDate − trialPeriodDaysand returnsnullfor reminders already due, reminders at/after trial end, non-offset or unparseable dates, and arithmetic overflow. The activity additionally subtracts the elapsed permission-dialog time, andNotificationSchedulerskips its sandboxdelay / 24 / 60compression for these. - Made
freeTrial_startidempotent per code — aMutexplus a persistedTrackedWebTrialCodesset, written only after a successfultrack, so a failed emit is retried rather than swallowed. - Rewrote
RedemptionResult.PaywallInfoto preserve the Kotlin JVM ABI —data class→ plain@Serializable classwithproductas a bodyvar … private set, a 7-param secondary constructor, twocopyoverloads,component1()–component7()and hand-writtenequals/hashCode/toString, so the originalcopy/copy$default/(…, int, DefaultConstructorMarker)descriptors stay callable from precompiled Kotlin.@SerialNamewas added to all 28PaywallProductfields. - Documented the eligibility/attribution decision —
handleTrialRedemptionnow states in code that the gate and the event's paywall identity deliberately come from the active paywall's presentation snapshot to match iOS, and the test assertspaywall_identifier/experiment_id/variant_id. - Tests —
WebTrialReminderTest(4 cases), five newTrialNotificationPermissionTestcases (delay subtraction, expiry during wait, cancellation,onDestroyrelease, sandbox bypass), six newWebRedemptionTrialTestcases, a reflection test pinning the old bytecode signatures, andtestOptions { unitTests.isIncludeAndroidResources = true }for the Robolectric activity test.
I verified the parts that carry the most risk. The two copy overloads cannot be ambiguous (the 7-param one's trailing product has no default) and their copy$default bridges have different arities; Class.getConstructor does return the synthetic default-marker constructor; the bitmask literals 32 and 61 select exactly the parameters the assertions claim; kotlinx.serialization serializes class-body properties with backing fields and the generated $serializer is a nested type, so private set is reachable on decode; no call site in the repo destructures PaywallInfo or uses it as a map key. I also traced every resume path and found no double-resume — the two catch blocks are mutually exclusive because checkAndRequestNotificationPermissions swallows its own synchronous callback's exceptions, and the inner one returns before the trailing resume(Unit). Finally, bypassing sandbox scaling for web reminders is right rather than a QA regression: Play sandbox compresses native trial durations so the native reminder is compressed to match, while a Stripe test-mode trial runs its real duration and the delay is already anchored to an absolute instant.
ℹ️ Nothing in this delta has been compiled or tested by CI
The most recent PR Tests run on this branch was against 1e34b38a; ddb61c8d, fb7b278c and e7d9289 each have no unit-test check run at all. That matters more than usual here, because the delta's correctness rests on things only a compiler and a test runner can confirm: whether @Serializable accepts a body var with a private set, whether the hand-written copy overloads and the plugin-generated constructors coexist without a signature clash, and whether the new Robolectric cases run at all under the freshly added isIncludeAndroidResources flag.
Technical details
# No CI validation exists for the current head
## Affected sites
- `e7d92897`, `fb7b278c`, `ddb61c8d` — `GET /repos/superwall/Superwall-Android/commits/<sha>/check-runs` returns only the `pullfrog` run (empty for `ddb61c8d`).
- `.github/workflows/pr-tests.yml` — `on: pull_request: branches: [develop]`; the workflow's run list shows `1e34b38a` (success, 2026-09-07T14:37Z) as the last run for `codex/fix-web-redemption-trials`.
- `superwall/build.gradle.kts:88-90` — newly added `testOptions { unitTests.isIncludeAndroidResources = true }`, never exercised by CI.
- `superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt:128-201` — hand-rolled ABI shims whose signature claims are only checkable at compile time.
## Required outcome
- A green `PR Tests` run against the commit that gets merged, not against a rewritten ancestor.
## Suggested approach
- Push an empty commit or re-run the workflow to confirm why the last two force-pushes did not trigger it, then confirm `:superwall:testDebugUnitTest` passes on `e7d9289`.ℹ️ Nitpicks
applySandboxScaling = falsequietly carries a second, unrelated meaning: it is also what selects the "subtract the permission-dialog wait from each delay" branch atSuperwallPaywallActivity.kt:932-940. A name closer to the intent —absoluteDelays, say — would read truer at theDependencyContainer.kt:1326call site, where the comment has to explain the coupling.WebRedemption.kt:149— the secondary constructor'sproductIdentifier: String? = nulldefault is unreachable positionally (the trailingproducthas no default) and no call site supplies it by name, so it only generates a$defaultbridge nobody calls.TrackedWebTrialCodes(CacheKeys.kt:325-329) is append-only — nothing prunes redeemed codes. Harmless at realistic code counts, but the set is persisted and unbounded.
Claude Opus | 𝕏
| continuation.invokeOnCancellation { | ||
| runOnUiThread { | ||
| if (notificationPermissionCallback === callback) notificationPermissionCallback = null | ||
| } | ||
| } |
There was a problem hiding this comment.
Cancelling the wait also cancels the scheduling here. When withTimeoutOrNull fires at 30s the system dialog is typically still on screen and the activity is still alive, but this clears notificationPermissionCallback, so a grant the user taps at 31s makes onRequestPermissionsResult no-op and the trial reminder is never scheduled — silently, with the trial itself already tracked. onDestroy (line 830) already covers the case where the activity actually goes away, so leaving the callback installed here would let a late grant still schedule best-effort.
Technical details
# A permission grant arriving after the 30s bound is discarded without a trace
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:960-964` — `invokeOnCancellation` nulls `notificationPermissionCallback` on timeout, not just on activity teardown.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:1047` — `notificationPermissionCallback?.onPermissionResult(isGranted)` then silently no-ops.
- `superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt:365-368` — the 30s `withTimeoutOrNull` that triggers it.
- `superwall/src/test/java/com/superwall/sdk/paywall/view/TrialNotificationPermissionTest.kt:110-128` — `cancelled permission wait ignores late and duplicate results` pins the current drop-on-cancel behaviour, so changing this is a deliberate decision, not an oversight.
## Required outcome
- A user who grants `POST_NOTIFICATIONS` at any point while the paywall activity is alive should get the trial reminder scheduled, even if the redemption stopped waiting for them.
## Suggested approach
- Drop the `invokeOnCancellation` clear and rely on `onDestroy` plus the replace-on-new-request path for cleanup; the callback already tolerates a completed continuation (`if (!continuation.isActive) return` at line 928 would need to move below the scheduling block instead of above it).
## Open questions for the human
- Is dropping the reminder the intended trade for bounding the wait, or is the bound only meant to unblock the redemption tail?
Changes in this pull request
didRedeemLink, tracksfreeTrial_startfor eligible trials, and schedules the active paywall's trial reminders when notification permission is granted.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues.