Conversation
Fix prices missing when products are restored from cache
`JsonElement.toPassableValue` matched `JsonNull` against its `is JsonPrimitive` branch, where every check failed and it fell to the catch-all that reads `content` - which for a null is the string "null". An audience filter asking `field == null` never matched, while `field == "null"` did. The sibling converter `convertFromJsonElement` already handles this, so the two now agree. Two existing assertions in JsonElementToPassableValueTest pinned the old behaviour and are updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: convert a JSON null to null, not to the text "null"
…/SWWebView.kt Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FY2V8fiWgvPpBhfp1Wjmc9
Pause/play media playback on backgrounded paywalls
…dering Fix paywall open nd template ordering
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Fix web redemption product details and trial handling
There was a problem hiding this comment.
ℹ️ No critical issues — two rough edges inline, two scope questions below.
Reviewed changes — full diff of the 2.8.3 release rollup (main → develop), covering the five already-merged sub-branches plus the version bump. I re-reviewed the merged code as an integrated whole rather than re-litigating the individual PRs.
- Web-trial redemption —
RedemptionResult.PaywallInfobecomes a hand-rolled@Serializable classso the 2.8.2 bytecode signatures survive while gaining a nestedPaywallProductcheckout snapshot;RedemptionStoreProductadapts it toStoreProductTypeandWebTrialReminderre-bases config delays onto the checkout timestamp. - Redeem flow reorder —
internallySetSubscriptionStatusis hoisted above thewhen (redemption)block so access lands before any permission wait, andcodeResultis computed once with a fallback that also removes a pre-existingNoSuchElementExceptionwhen the requested code is missing from the response. freeTrial_startdedup — new persistedTrackedWebTrialCodeskey plus aMutexso overlapping same-code redemptions emit once.- Notification scheduling —
attemptToScheduleNotificationsmoves tosuspendCancellableCoroutine, resumes fromonDestroy, releases a replaced waiter, subtracts the permission wait from absolute web delays, and skips sandbox/24/60scaling for web reminders. - Paywall message ordering —
paywall_open/paywall_closenow wait on every in-flighttemplate_variablessend (bounded atTEMPLATE_OPEN_WAIT_MS), withresetForWebViewReloadcancelling old-document work and re-queueing an open guarded by alastOpenidentity check. - Media playback —
MediaPlaybackScriptplusSWWebViewlifecycle overrides keep hidden/preloaded paywall video paused;SuperwallPaywallActivityforwardsonPause/onResume. - Two small fixes —
ProductItemSerializerfalls back fromreference_nametoproductso disk-cached config keeps template reference names, andJsonElement.toPassableValue()gains anis JsonNullbranch ahead ofis JsonPrimitive.
I traced the lifecycleLock / inFlightTemplateSends machinery for deadlock, stranded queue entries and double delivery, and found none that this diff introduces: every queue mutation is serialized on the main thread (onRenderProcessGone → recreateWebview, and both flushPendingMessagesInternal call sites), and the lock is never held across a suspension. The added tests are genuine — TrialNotificationPermissionTest asserts the exact post-subtraction delay, SWWebViewMediaLifecycleTest alternates true/false on every transition so no assertion passes vacuously, and WebRedemptionTrialTest pins the full access → trial → schedule → restore → close → callback order.
ℹ️ The media-playback fix only reaches activity-hosted paywalls
hostPaused is only ever set by SuperwallPaywallActivity.onPause/onResume. superwall-compose's PaywallComposable uses a bare AndroidView with no lifecycle observer, so for embedded paywalls hostPaused stays false for the view's whole life and media is gated solely by attach/visibility state — which does not change on every backgrounding path. The changelog entry reads as a general fix, but compose hosts keep the old behavior.
Technical details
# Compose-hosted paywalls miss the new media pause/resume wiring
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:793,807` — the only production callers of `SWWebView.onResume()`/`onPause()`.
- `superwall-compose`'s `PaywallComposable` — no `DisposableEffect`/`LifecycleEventObserver`, so neither is ever called.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt:193` — `!hostPaused` is a permanent no-op for that host.
## Required outcome
- Either forward the host lifecycle to `SWWebView.onPause()`/`onResume()` from the compose integration, or scope the changelog entry to activity-presented paywalls so the gap is explicit.
## Open questions for the human
- Is `superwall-compose` in scope for 2.8.3, or is activity-only parity acceptable for this patch?ℹ️ A web-trial redemption can hold the paywall open for up to 30 s
handleTrialRedemption runs before triggerRestoreInPaywall, closePaywallIfExists and didRedeemLink, and can block on factory.scheduleTrialNotifications(...) for WEB_TRIAL_NOTIFICATION_TIMEOUT_MILLIS. Entitlement access is already applied by then, so this is not an access bug — but if the user leaves the notification-permission dialog unanswered without the activity being destroyed, the paywall stays up and the didRedeemLink delegate callback does not fire for the full 30 s. The tests pin this ordering deliberately, so the question is whether the bound is the one you want.
Technical details
# Redeem completion is gated on the notification-permission dialog
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt:279` — `handleTrialRedemption(codeResult)` is awaited before the restore check and the trailing `closePaywallIfExists()` / `didRedeemLink(codeResult)` at lines 297-299.
- `superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt:52,361` — the 30 s bound.
## Required outcome
- Confirm 30 s is the intended worst-case delay for paywall dismissal and the `didRedeemLink` delegate callback, or move the reminder scheduling off the completion path so dismissal is not gated on it.
## Open questions for the human
- The changelog says "Notification permission waits no longer block access"; should it also say dismissal and `didRedeemLink` are still deferred until the wait resolves or times out?ℹ️ Nitpicks
superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt:113— leftover debugprintln("Product bindings before refresh: …").TrackedWebTrialCodes(superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt:325) is only ever unioned — nothing prunes it, and neitherLocalStorage.reset()(it lives inAPP_SPECIFIC_DOCUMENTS, notcacheDir) norWebPaywallRedeemer.clear(ownership)touches it. Growth is negligible, but worth a deliberate decision on whether a logout should clear it.
Claude Opus | 𝕏
…/PaywallWebUI.kt Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
trialPeriodEndDate only caught DateTimeParseException, so an overflowing date made toEpochMilli() throw ArithmeticException out of the lazy on every read. It also used Instant.parse, which in threetenbp rejects offset timestamps that webTrialReminderDelay accepts. Parse with OffsetDateTime and catch DateTimeException/ArithmeticException to match the reminder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XmJQyMUf6TdAoVa6jYMb12
There was a problem hiding this comment.
Caution
This PR no longer compiles. 24a94eaa makes SWWebView.kt:200 and :204 ambiguous — confirmed by the unit-tests job on this SHA.
Reviewed changes — one commit since the prior pullfrog review (3d5db14).
- Gave
PaywallWebUI.onPause()/onResume()empty default bodies — applies the prior review's source-compatibility suggestion so external implementors of the public interface aren't broken by a patch release.
That suggestion was mine and it was wrong as written: I checked the interface's compatibility surface but not the super. call sites in its only production implementor. SWWebView extends WebView, which already has concrete onPause() / onResume(), so the interface defaults create two inherited implementations and the bare super.onPause() / super.onResume() added in this PR become ambiguous. Reproduced locally with kotlinc -language-version 2.0 against both a Kotlin and a Java superclass, and the CI log for 24a94ea shows exactly:
e: SWWebView.kt:200:9 Multiple supertypes available. Please specify the intended supertype in angle brackets, e.g. 'super<Foo>'.
e: SWWebView.kt:204:9 Multiple supertypes available. Please specify the intended supertype in angle brackets, e.g. 'super<Foo>'.
Qualifying the two super calls fixes it and keeps the compatibility win — inline suggestion below. Worth noting the interface's other View-shadowing members (scrollBy, scrollTo) stay abstract and are satisfied by View's implementations, which is why they never hit this.
The RedemptionStoreProduct.trialPeriodEndDate ArithmeticException thread from the prior review is unchanged by this commit and remains open.
ℹ️ Nitpicks
- Now that the interface has defaults,
FakePaywallWebUI'soverride fun onPause() = Unit/onResume() = Unitare redundant in bothPaywallViewTest.kt:520,522andPaywallMessageHandlerTest.kt:383,385.
Claude Opus | 𝕏
PaywallWebUI now provides default onPause/onResume bodies, so SWWebView inherits concrete implementations from both WebView and PaywallWebUI. Qualify the super calls with WebView to resolve the ambiguity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbUwGJ5vnr82Mag98Y2J2f
There was a problem hiding this comment.
✅ No new issues found. The build break from
24a94eaais fixed and every prior thread is resolved.
Reviewed changes — one commit since the prior pullfrog review (3025a1e).
- Qualified the two ambiguous
supercalls inSWWebView—super<WebView>.onPause()/super<WebView>.onResume()replace the bare forms that stopped resolving oncePaywallWebUIgained default bodies.
This is the right fix rather than reverting the interface defaults: WebView's implementations are what actually suspend timers and media, and the no-op defaults stay in place so external Kotlin implementors of the public PaywallWebUI aren't broken by a patch release. I verified both directions locally with kotlinc against a Java superclass — the qualified form compiles clean with no warnings, the bare form still fails at both call sites with Multiple supertypes available. CI (unit-tests, build) was still running when I submitted, so that's the confirming signal to watch.
Nothing else changed in this delta, and all three review threads on this PR are now resolved. The scope questions from the first review (compose-hosted media parity, the up-to-30 s didRedeemLink deferral, TrackedWebTrialCodes eviction) are unchanged and remain yours to decide — not blockers.
Claude Opus | 𝕏

Changes in this pull request
2.8.3
Fixes
didRedeemLink, tracksfreeTrial_startonce 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.paywall_opennow waits for an in-flighttemplate_variablessend, so the runtime does not treat a late template payload as a fresh load and drop laterpage_views."null", so a filter checking whether a field is null never matched.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues.