Repository navigation
Schedule notifications for paywalls embedded in the app's Activity - #495
Merged
Merged
Conversation
Trial reminders and the paywall's ScheduleNotification messages were only scheduled when the paywall was shown in a SuperwallPaywallActivity or an InWindowPaywallHost. A paywall embedded with PaywallComposable lives in the app's own Activity, so PaywallNotifications.forPaywall returned null and the SDK logged a warning instead of scheduling. forPaywall now falls back to the Activity the view is embedded in (set by PaywallBuilder.activity). It asks for POST_NOTIFICATIONS with requestPermissionFrom, the same helper the in-window host uses, so it works for a ComponentActivity and a plain Activity. The instance is kept on the PaywallView and outlives cleanup(), so a permission granted after the composable is released still schedules; a pending request is released when a lifecycle-aware Activity is destroyed. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/opus
ianrumac
force-pushed
the
fix/embedded-paywall-notifications
branch
from
November 5, 2026 15:05
a77f9a6 to
3f3f852
Compare
ianrumac
marked this pull request as ready for review
November 5, 2026 16:11
This comment has been minimized.
This comment has been minimized.
The permission request resolves the Activity through a weak reference, so a destroyed host isn't retained by the PaywallView that PaywallManager caches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Maple review🟢 Confidence 10/10 · safe to merge. The focused change reuses existing permission and scheduling paths, with regression tests covering embedded-host selection and pending-request lifetime. Adds trial-reminder scheduling for embedded paywalls while preserving pending permission results after view cleanup. The change is safe to merge; no confirmed defects found.
|
ianrumac
added a commit
that referenced
this pull request
Nov 6, 2026
* Remove kotlinx-coroutines-guava dependency The only use was awaiting WorkManager.getWorkInfosByTag in NotificationScheduler. Use WorkManager's getWorkInfosByTagFlow instead, which drops the transitive full Guava jar from the SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAoXMCfJDDrqBG4cYiF * refactor: add the public API guard * refactor: retire iOS-port leftovers (3.5) * Update coverage badge [skip ci] * fix: apply requested changes * refactor: event handler and presentation * refactor: bootstrap, status and observers * refactor: remaining blocks * fix: apply requested changes * refactor: SuperwallGraph and tracking * refactor: options, status, identity, config * refactor: tests and locator guard * fix: address review findings * fix: apply requested changes * refactor: contexts and analytics * refactor: PurchaseFlow actor * refactor: RestoreFlow actor * fix: apply requested changes * refactor: ObserverModeTracker actor * fix: apply requested changes * fix: apply requested changes * Update coverage badge [skip ci] * Update coverage badge [skip ci] * fix: apply requested changes * Update coverage badge [skip ci] * fix: apply requested changes * fix: apply requested changes * fix: apply requested changes * refactor: move PaywallView onto actor primitives (#504) * fix: report the base plan bought in customer info (#506) * fix: report the base plan bought in customer info Play's Purchase only names the subscription, so with several base plans under one subscription, customer info used the first one in the config and based the expiry on that plan's period. Save the product bought against its purchase token. When the plan can't be known, report the subscription ID with no expiry instead of guessing, and clear plans and expiries stored by earlier versions that can't be verified. Tokens from before a purchase are never matched to it, so a deferred plan change doesn't relabel the purchase it replaces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Fix storage, network, billing, observer mode, restore and localization bugs (#498) * Fix storage, network, billing, observer mode, restore and localization bugs - Cache: user-scope reset via tombstones instead of wiping the host's cacheDir; AtomicFile writes; thread-safe memory cache; tombstoned deletes. - Reset wipes storage before minting the new identity. - Network wait accepts any transport with INTERNET, uses the default network callback and gives up after 30s. - Product cache is per wrapper instance, never caches misses as errors, retries missing ids after 5 minutes, clears on reset. - Device receipts no longer guess expiry/grace; PURCHASED is SUBSCRIBED, PENDING is pending (not revoked). Removes dead latestPurchase(). - Observer mode uses a single in-flight slot, handled once by either path. - GoogleBillingWrapper is the only BillingClient; AutomaticPurchaseController launches flows, acknowledges and queries through it, and the purchase result carries the Play purchase. - Paywall activity finishes when recreated without its paywall. - Deprecates PaywallOptions.useCachedTemplates. - Localizes the restore-failed alert and PaywallComposable's error; the composable reloads on placement change and no longer throws without an Activity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gApsMVje34EFAktGWwR * Address review: failed Play reads, observer slot, reset, cache race - A failed Play read is no longer treated as "no purchases": receipts and customer info aren't overwritten and latestSubscriptionState isn't set to EXPIRED. Billing gains queryAllPurchasesResult(), which waits for the connection and fails instead of returning an empty list. - Play subscribers stay active through a failed read until a hold date (last confirmed + one subscription period), stored separately from the user-facing expiry, which stays unknown. - A new observed purchase replaces one that never reported an outcome and tracks it as abandoned, so observer mode can't get stuck. - After reset/user switch, the new user's receipts are rebuilt from Play. - Cache: memory updates and queued disk work happen under one lock, and a disk read that races clearUserScope doesn't resurrect the old value. - The billing flow is launched on the main thread. - CacheInstrumentedTest uses clearUserScope(); the PaywallComposable placement test now fails without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gApsMVje34EFAktGWwR * Fix CI and address review findings - Restore gradle versions, wrapper and superwall/build.gradle.kts the IDE downgraded - Keep restore-failed text the app sets explicitly, even when it equals the English default - Leave pending purchases out of published purchase history; drop cancelled pending purchases - Drop expiry dates older versions inferred for stored Play transactions - Fix GoogleBillingWrapper companion parsed as a companion named 'internal' - Update API dump and tests for the new billing semantics Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] * Rethrow cancellation in lastSubscriptionProperties; stub view context in restore tests - F4: a cancelled product lookup no longer computes and publishes a state - androidTest PaywallView mock provides a context for the localized restore-failed alert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read cache files directly so a read can't discard a write in progress AtomicFile.openRead() deletes the .new file whenever the base exists, which drops a write the ioQueue is still making to the same key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Schedule notifications for paywalls embedded in the app's Activity (#495) * Schedule notifications for paywalls embedded in the app's Activity Trial reminders and the paywall's ScheduleNotification messages were only scheduled when the paywall was shown in a SuperwallPaywallActivity or an InWindowPaywallHost. A paywall embedded with PaywallComposable lives in the app's own Activity, so PaywallNotifications.forPaywall returned null and the SDK logged a warning instead of scheduling. forPaywall now falls back to the Activity the view is embedded in (set by PaywallBuilder.activity). It asks for POST_NOTIFICATIONS with requestPermissionFrom, the same helper the in-window host uses, so it works for a ComponentActivity and a plain Activity. The instance is kept on the PaywallView and outlives cleanup(), so a permission granted after the composable is released still schedules; a pending request is released when a lifecycle-aware Activity is destroyed. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/opus * Update coverage badge [skip ci] * Update coverage badge [skip ci] * Don't hold the embedding Activity in cached paywall notifications The permission request resolves the Activity through a weak reference, so a destroyed host isn't retained by the PaywallView that PaywallManager caches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Kanna <noreply@kanna.sh> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ian Rumac <ian@superwall.com> Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat: report FCM push tokens for Superwall notifications (#494) * feat: report FCM push tokens for Superwall notifications Adds Superwall.setPushToken(token), optOutOfNotifications(), optInToNotifications() and handleNotificationOpen(intent | data), mirroring superwall/Superwall-iOS#571 for the FCM channel. NotificationTokenManager sends a notification_token event (channel "fcm", address, permissionStatus, enabled, appVersion, sdkVersion) on token, permission (re-checked on foreground), opt and identity changes, otherwise at most once a day, deduped by a SHA-256 fingerprint persisted in app-specific storage. Nothing is sent until the user_notifications flag is on (the state is cached and flushed once it is), or while eventTrackingBehavior is NONE. notification_token and notification_open are PrivateSuperwallEvents: queued for the collector with $is_standard_event, but never passed to the delegate, stored on-device or able to trigger a paywall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep push tokens out of logs and send them once tracking is back on A failed /events request logged the whole EventsRequest, including the notification_token address, and Logger delivers every log to SuperwallDelegate.handleLog. It now logs only the event count and names. A cache entry that fails to decode no longer logs its contents or the decoding exception, whose message quotes the JSON it read. Turning eventTrackingBehavior back on refreshed the token before the events queue applied the new behavior, so the queue dropped the event while the manager marked it sent, delaying it to the next day's heartbeat. The queue now exposes its own behavior as a StateFlow; the manager gates on it and refreshes when it turns tracking back on, instead of the setter calling refresh. Docs: setPushToken needs configure first and re-sends daily; handleNotificationOpen is for taps only, and the intent form needs the sw extra copied into app-built notifications' tap intents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: update notification tests for develop's logging and tracking * Update coverage badge [skip ci] * Fix notification heartbeat after clock rollback and dropped registrations - shouldSend resends when the last send is in the future (clock moved back) - EventsQueue.enqueueAccepted checks tracking and adds atomically, and reports whether the event was kept; the token is only marked sent when it was - Forget the last send when tracking moves off ALL, since the queue drops what it holds then - Update the API dump for the PR's new public API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Pause preloaded paywall webviews while off screen (#496) * Pause preloaded paywall webviews while off screen A preloaded paywall's webview loads off screen and then waits in the cache. A webview that is not in a window still reports its page as visible, and every webview in the app shares one renderer main thread, so cached paywalls kept running their requestAnimationFrame, CSS and Lottie animations unseen, slowing the paywall on screen and the host app. SWWebView only paused <video> and <audio> (MediaPlaybackScript). SWWebView now calls WebView.onPause() once a paywall it holds is ready while it is off screen (PaywallView.updateState -> PaywallWebUI.onPaywallReady), and when it is detached, and resumes on attach and on reload. WebView.onPause() stops timers as well as animation frames, so a preload is paused only after it reached PaywallLoadingState.Ready; before that it must run to load and accept its templates. Test: SWWebViewMediaLifecycleTest, a preload stops rendering once it is ready and off screen. * Only pause a detached paywall webview once it is ready Pausing on detach stopped the page's timers even when it was still loading, which left a paywall dismissed mid-load stuck half-loaded in the cache. Detach now pauses only after the loaded page reached Ready; a reload clears that. Also moves the new tests onto develop's testSWWebView helper and records PaywallWebUI.onPaywallReady in the API dump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Stop logging "Error Evaluating JS" after every paywall message (#510) * Stop logging "Error Evaluating JS" after every paywall message WebView.evaluateJavascript passes the script's return value, not an error. window.paywall.accept64 returns nothing, which Android reports as the string "null", so every successful post (template variables, callback results, tracking behavior) logged "Error Evaluating JS" with java.lang.Exception: null. A Unity customer read it as the reason their custom callback failed. Skip a null or "null" result; log any other value as before. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/opus * Update coverage badge [skip ci] --------- Co-authored-by: Kanna <noreply@kanna.sh> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Fix custom store product purchases being dropped from paywalls (#512) * Fix custom store product purchases being dropped from paywalls (#509) The products template only listed Play Store products, so a paywall never learned a custom product's id and posted `purchase` without `product_identifier`. The parser force-unwrapped that field and the NPE discarded the whole message batch. - Send custom store products in the `products` template, with their product identifier (the key they're cached under) as `productId`. - Make `product_identifier` optional; the handler resolves a missing id from the paywall product with the message's reference name. - Skip a single unparseable message instead of dropping the whole batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Dismiss the paywall when its activity is finished while stopped (#513) * Dismiss the paywall when its activity is finished while stopped (#501) When SuperwallPaywallActivity was stopped without finishing and finished later (e.g. its task cleared from the background), onStop only marked the paywall closed for background and onDestroy never ran the finishing teardown. activePaywallVcKey stayed set, so isPaywallPresented stayed true and every later register() failed with "Paywall Already Presented". onDestroy now runs the finishing teardown (willDismiss, destroyed with forceCleanup) when the activity is finishing and onStop didn't already start it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix flaky in-window backdrop-tap dismiss test The test polled paywallView.state.paywallResult after idling the main looper, but the full teardown (CleanupAfterDestroy) resets paywallResult to null. dismissView() runs on a real IO thread, so when it posted the close before the first idleFor(700), the teardown finished before the first check and the test timed out. Assert on the PaywallState.Dismissed the teardown emits instead, which carries the result and close reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Don't track a second paywall_close when a backgrounded paywall is torn down A paywall stopped without finishing tracks paywall_close and sets closedForBackground. When its activity is then finished while still stopped, the finishing teardown tracked another paywall_close with no paywall_open in between. Skip the teardown's close when the background close was already tracked, so each time the paywall leaves the screen is counted once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Version bump * Fix chngelog * Update paywall preload benchmark report [skip ci] * Redesign the README around agent install (#507) * Redesign the README around agent install Match the iOS README: the superwall.com banner, an AI-first tagline, the single-prompt agent install first, then a manual quick start with copyable Gradle, manifest, configure and register steps. Badges drop to version, license and coverage, and the emoji feature table goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Yusuf Tör <3296904+yusuftor@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ian Rumac <ian@superwall.com> * Report store CUSTOM on custom store product transaction events (#515) * Fix custom store product purchases being dropped from paywalls (#509) The products template only listed Play Store products, so a paywall never learned a custom product's id and posted `purchase` without `product_identifier`. The parser force-unwrapped that field and the NPE discarded the whole message batch. - Send custom store products in the `products` template, with their product identifier (the key they're cached under) as `productId`. - Make `product_identifier` optional; the handler resolves a missing id from the paywall product with the message's reference name. - Skip a single unparseable message instead of dropping the whole batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] * Report store CUSTOM on custom store product transaction events Transaction events for custom products defaulted to PLAY_STORE, so analytics counted purchases made through the developer's own checkout as Google Play revenue. Mirrors superwall/Superwall-iOS#552. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fetch entitlements after a custom store purchase (#516) A custom product purchase has no Play receipt, so the SDK never asked the backend for the entitlements the developer's server reported. Run the background /redeem after a successful custom purchase, as after a Play purchase. A background redeem whose user changed in flight is dropped instead of being applied to the new user. Mirrors superwall/Superwall-iOS#552. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ian Rumac <ian@superwall.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Adedeji Toki <deji@fourfoursoftware.com> Co-authored-by: Brian Anglin <brianranglin@gmail.com> Co-authored-by: Kanna <noreply@kanna.sh> Co-authored-by: Leonardo Bilck <leonardo@plotkids.com> Co-authored-by: Yusuf Tör <yusuf@yusuftor.co.uk> Co-authored-by: Yusuf Tör <3296904+yusuftor@users.noreply.github.com> Co-authored-by: Makisuo <31933546+Makisuo@users.noreply.github.com>
ianrumac
added a commit
that referenced
this pull request
Nov 9, 2026
* Remove kotlinx-coroutines-guava dependency The only use was awaiting WorkManager.getWorkInfosByTag in NotificationScheduler. Use WorkManager's getWorkInfosByTagFlow instead, which drops the transitive full Guava jar from the SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAoXMCfJDDrqBG4cYiF * refactor: add the public API guard * refactor: retire iOS-port leftovers (3.5) * Update coverage badge [skip ci] * fix: apply requested changes * refactor: event handler and presentation * refactor: bootstrap, status and observers * refactor: remaining blocks * fix: apply requested changes * refactor: SuperwallGraph and tracking * refactor: options, status, identity, config * refactor: tests and locator guard * fix: address review findings * fix: apply requested changes * refactor: contexts and analytics * refactor: PurchaseFlow actor * refactor: RestoreFlow actor * fix: apply requested changes * refactor: ObserverModeTracker actor * fix: apply requested changes * fix: apply requested changes * Update coverage badge [skip ci] * Update coverage badge [skip ci] * fix: apply requested changes * Update coverage badge [skip ci] * fix: apply requested changes * fix: apply requested changes * fix: apply requested changes * refactor: move PaywallView onto actor primitives (#504) * fix: report the base plan bought in customer info (#506) * fix: report the base plan bought in customer info Play's Purchase only names the subscription, so with several base plans under one subscription, customer info used the first one in the config and based the expiry on that plan's period. Save the product bought against its purchase token. When the plan can't be known, report the subscription ID with no expiry instead of guessing, and clear plans and expiries stored by earlier versions that can't be verified. Tokens from before a purchase are never matched to it, so a deferred plan change doesn't relabel the purchase it replaces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Fix storage, network, billing, observer mode, restore and localization bugs (#498) * Fix storage, network, billing, observer mode, restore and localization bugs - Cache: user-scope reset via tombstones instead of wiping the host's cacheDir; AtomicFile writes; thread-safe memory cache; tombstoned deletes. - Reset wipes storage before minting the new identity. - Network wait accepts any transport with INTERNET, uses the default network callback and gives up after 30s. - Product cache is per wrapper instance, never caches misses as errors, retries missing ids after 5 minutes, clears on reset. - Device receipts no longer guess expiry/grace; PURCHASED is SUBSCRIBED, PENDING is pending (not revoked). Removes dead latestPurchase(). - Observer mode uses a single in-flight slot, handled once by either path. - GoogleBillingWrapper is the only BillingClient; AutomaticPurchaseController launches flows, acknowledges and queries through it, and the purchase result carries the Play purchase. - Paywall activity finishes when recreated without its paywall. - Deprecates PaywallOptions.useCachedTemplates. - Localizes the restore-failed alert and PaywallComposable's error; the composable reloads on placement change and no longer throws without an Activity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gApsMVje34EFAktGWwR * Address review: failed Play reads, observer slot, reset, cache race - A failed Play read is no longer treated as "no purchases": receipts and customer info aren't overwritten and latestSubscriptionState isn't set to EXPIRED. Billing gains queryAllPurchasesResult(), which waits for the connection and fails instead of returning an empty list. - Play subscribers stay active through a failed read until a hold date (last confirmed + one subscription period), stored separately from the user-facing expiry, which stays unknown. - A new observed purchase replaces one that never reported an outcome and tracks it as abandoned, so observer mode can't get stuck. - After reset/user switch, the new user's receipts are rebuilt from Play. - Cache: memory updates and queued disk work happen under one lock, and a disk read that races clearUserScope doesn't resurrect the old value. - The billing flow is launched on the main thread. - CacheInstrumentedTest uses clearUserScope(); the PaywallComposable placement test now fails without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gApsMVje34EFAktGWwR * Fix CI and address review findings - Restore gradle versions, wrapper and superwall/build.gradle.kts the IDE downgraded - Keep restore-failed text the app sets explicitly, even when it equals the English default - Leave pending purchases out of published purchase history; drop cancelled pending purchases - Drop expiry dates older versions inferred for stored Play transactions - Fix GoogleBillingWrapper companion parsed as a companion named 'internal' - Update API dump and tests for the new billing semantics Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] * Rethrow cancellation in lastSubscriptionProperties; stub view context in restore tests - F4: a cancelled product lookup no longer computes and publishes a state - androidTest PaywallView mock provides a context for the localized restore-failed alert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read cache files directly so a read can't discard a write in progress AtomicFile.openRead() deletes the .new file whenever the base exists, which drops a write the ioQueue is still making to the same key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Schedule notifications for paywalls embedded in the app's Activity (#495) * Schedule notifications for paywalls embedded in the app's Activity Trial reminders and the paywall's ScheduleNotification messages were only scheduled when the paywall was shown in a SuperwallPaywallActivity or an InWindowPaywallHost. A paywall embedded with PaywallComposable lives in the app's own Activity, so PaywallNotifications.forPaywall returned null and the SDK logged a warning instead of scheduling. forPaywall now falls back to the Activity the view is embedded in (set by PaywallBuilder.activity). It asks for POST_NOTIFICATIONS with requestPermissionFrom, the same helper the in-window host uses, so it works for a ComponentActivity and a plain Activity. The instance is kept on the PaywallView and outlives cleanup(), so a permission granted after the composable is released still schedules; a pending request is released when a lifecycle-aware Activity is destroyed. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/opus * Update coverage badge [skip ci] * Update coverage badge [skip ci] * Don't hold the embedding Activity in cached paywall notifications The permission request resolves the Activity through a weak reference, so a destroyed host isn't retained by the PaywallView that PaywallManager caches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Kanna <noreply@kanna.sh> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ian Rumac <ian@superwall.com> Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat: report FCM push tokens for Superwall notifications (#494) * feat: report FCM push tokens for Superwall notifications Adds Superwall.setPushToken(token), optOutOfNotifications(), optInToNotifications() and handleNotificationOpen(intent | data), mirroring superwall/Superwall-iOS#571 for the FCM channel. NotificationTokenManager sends a notification_token event (channel "fcm", address, permissionStatus, enabled, appVersion, sdkVersion) on token, permission (re-checked on foreground), opt and identity changes, otherwise at most once a day, deduped by a SHA-256 fingerprint persisted in app-specific storage. Nothing is sent until the user_notifications flag is on (the state is cached and flushed once it is), or while eventTrackingBehavior is NONE. notification_token and notification_open are PrivateSuperwallEvents: queued for the collector with $is_standard_event, but never passed to the delegate, stored on-device or able to trigger a paywall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep push tokens out of logs and send them once tracking is back on A failed /events request logged the whole EventsRequest, including the notification_token address, and Logger delivers every log to SuperwallDelegate.handleLog. It now logs only the event count and names. A cache entry that fails to decode no longer logs its contents or the decoding exception, whose message quotes the JSON it read. Turning eventTrackingBehavior back on refreshed the token before the events queue applied the new behavior, so the queue dropped the event while the manager marked it sent, delaying it to the next day's heartbeat. The queue now exposes its own behavior as a StateFlow; the manager gates on it and refreshes when it turns tracking back on, instead of the setter calling refresh. Docs: setPushToken needs configure first and re-sends daily; handleNotificationOpen is for taps only, and the intent form needs the sw extra copied into app-built notifications' tap intents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: update notification tests for develop's logging and tracking * Update coverage badge [skip ci] * Fix notification heartbeat after clock rollback and dropped registrations - shouldSend resends when the last send is in the future (clock moved back) - EventsQueue.enqueueAccepted checks tracking and adds atomically, and reports whether the event was kept; the token is only marked sent when it was - Forget the last send when tracking moves off ALL, since the queue drops what it holds then - Update the API dump for the PR's new public API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Pause preloaded paywall webviews while off screen (#496) * Pause preloaded paywall webviews while off screen A preloaded paywall's webview loads off screen and then waits in the cache. A webview that is not in a window still reports its page as visible, and every webview in the app shares one renderer main thread, so cached paywalls kept running their requestAnimationFrame, CSS and Lottie animations unseen, slowing the paywall on screen and the host app. SWWebView only paused <video> and <audio> (MediaPlaybackScript). SWWebView now calls WebView.onPause() once a paywall it holds is ready while it is off screen (PaywallView.updateState -> PaywallWebUI.onPaywallReady), and when it is detached, and resumes on attach and on reload. WebView.onPause() stops timers as well as animation frames, so a preload is paused only after it reached PaywallLoadingState.Ready; before that it must run to load and accept its templates. Test: SWWebViewMediaLifecycleTest, a preload stops rendering once it is ready and off screen. * Only pause a detached paywall webview once it is ready Pausing on detach stopped the page's timers even when it was still loading, which left a paywall dismissed mid-load stuck half-loaded in the cache. Detach now pauses only after the loaded page reached Ready; a reload clears that. Also moves the new tests onto develop's testSWWebView helper and records PaywallWebUI.onPaywallReady in the API dump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Stop logging "Error Evaluating JS" after every paywall message (#510) * Stop logging "Error Evaluating JS" after every paywall message WebView.evaluateJavascript passes the script's return value, not an error. window.paywall.accept64 returns nothing, which Android reports as the string "null", so every successful post (template variables, callback results, tracking behavior) logged "Error Evaluating JS" with java.lang.Exception: null. A Unity customer read it as the reason their custom callback failed. Skip a null or "null" result; log any other value as before. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/opus * Update coverage badge [skip ci] --------- Co-authored-by: Kanna <noreply@kanna.sh> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Fix custom store product purchases being dropped from paywalls (#512) * Fix custom store product purchases being dropped from paywalls (#509) The products template only listed Play Store products, so a paywall never learned a custom product's id and posted `purchase` without `product_identifier`. The parser force-unwrapped that field and the NPE discarded the whole message batch. - Send custom store products in the `products` template, with their product identifier (the key they're cached under) as `productId`. - Make `product_identifier` optional; the handler resolves a missing id from the paywall product with the message's reference name. - Skip a single unparseable message instead of dropping the whole batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Dismiss the paywall when its activity is finished while stopped (#513) * Dismiss the paywall when its activity is finished while stopped (#501) When SuperwallPaywallActivity was stopped without finishing and finished later (e.g. its task cleared from the background), onStop only marked the paywall closed for background and onDestroy never ran the finishing teardown. activePaywallVcKey stayed set, so isPaywallPresented stayed true and every later register() failed with "Paywall Already Presented". onDestroy now runs the finishing teardown (willDismiss, destroyed with forceCleanup) when the activity is finishing and onStop didn't already start it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix flaky in-window backdrop-tap dismiss test The test polled paywallView.state.paywallResult after idling the main looper, but the full teardown (CleanupAfterDestroy) resets paywallResult to null. dismissView() runs on a real IO thread, so when it posted the close before the first idleFor(700), the teardown finished before the first check and the test timed out. Assert on the PaywallState.Dismissed the teardown emits instead, which carries the result and close reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Don't track a second paywall_close when a backgrounded paywall is torn down A paywall stopped without finishing tracks paywall_close and sets closedForBackground. When its activity is then finished while still stopped, the finishing teardown tracked another paywall_close with no paywall_open in between. Skip the teardown's close when the background close was already tracked, so each time the paywall leaves the screen is counted once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Version bump * Fix chngelog * Update paywall preload benchmark report [skip ci] * Redesign the README around agent install (#507) * Redesign the README around agent install Match the iOS README: the superwall.com banner, an AI-first tagline, the single-prompt agent install first, then a manual quick start with copyable Gradle, manifest, configure and register steps. Badges drop to version, license and coverage, and the emoji feature table goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Yusuf Tör <3296904+yusuftor@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ian Rumac <ian@superwall.com> * Report store CUSTOM on custom store product transaction events (#515) * Fix custom store product purchases being dropped from paywalls (#509) The products template only listed Play Store products, so a paywall never learned a custom product's id and posted `purchase` without `product_identifier`. The parser force-unwrapped that field and the NPE discarded the whole message batch. - Send custom store products in the `products` template, with their product identifier (the key they're cached under) as `productId`. - Make `product_identifier` optional; the handler resolves a missing id from the paywall product with the message's reference name. - Skip a single unparseable message instead of dropping the whole batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] * Report store CUSTOM on custom store product transaction events Transaction events for custom products defaulted to PLAY_STORE, so analytics counted purchases made through the developer's own checkout as Google Play revenue. Mirrors superwall/Superwall-iOS#552. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fetch entitlements after a custom store purchase (#516) A custom product purchase has no Play receipt, so the SDK never asked the backend for the entitlements the developer's server reported. Run the background /redeem after a successful custom purchase, as after a Play purchase. A background redeem whose user changed in flight is dropped instead of being applied to the new user. Mirrors superwall/Superwall-iOS#552. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ian Rumac <ian@superwall.com> * Keep test mode purchases in the status when a selection is active (#522) * Keep test mode purchases in the status when a selection is active When the tester had chosen an active entitlement in the modal, the status was built from the selections alone and dropped the entitlements bought afterwards. Bought entitlements are now added as active next to the selections, which keep their chosen state, as on iOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Spell out that bought test mode entitlements are active Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Yusuf Tör <3296904+yusuftor@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * ci: let a manual dispatch publish a release (#519) The tag and release checks only ran on push, so a dispatched run skipped deploy, tag, release and wrapper notification while reporting success. The tag check still keeps a version from being published twice. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Keep the paywall cache's active key pointing at a cached view (#521) * Keep the paywall cache's active key pointing at a cached view Removing or replacing the paywall on screen left its key behind, so a view saved later under the same identifier was taken for the one on screen and survived removeAll. Every update now clears the key when its view goes, and setting it to a key with nothing cached leaves nothing active, as on iOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] * Update coverage badge [skip ci] --------- Co-authored-by: Yusuf Tör <3296904+yusuftor@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ian Rumac <ian@superwall.com> * Buy the base plan for sw-none products instead of the free trial (SW-5453) (#523) * Buy the base plan for sw-none products instead of the free trial (SW-5453) RawStoreProduct resolves the paywall product's offer (sw-none to the base plan, sw-auto to the best offer, a named offer to that offer), and TransactionManager passes the resolved offer id to the PurchaseController. The base plan has no offer id, so a sw-none purchase arrived as offerId = null. AutomaticPurchaseController rebuilt the product with OfferType.from(null), which is Auto since 2.7.0, so it picked the longest free trial and sent that token to Play. The controller now treats a null offerId as no offer. OfferType.from(null) stays Auto: that is right for parsing a product id with no offer part ("sub:plan"), just not for an offer id that was already resolved. Documents that a null offerId means the base plan for custom PurchaseControllers, and adds regression tests for sw-none, sw-auto and a named offer on a base plan with a trial, plus sw-none on a one-time product with a discount offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Fix horizontal system-bar insets in paywall activity (SW-6374) (#520) * Fix horizontal system-bar insets in paywall activity (SW-6374) * Stabilize paywall inset device regression fixture * Override onNewIntent(Intent) instead of the API 35 ComponentCaller variant Android 14 and below never call onNewIntent(Intent, ComponentCaller), so deep links delivered through onNewIntent were ignored there. The API 35 type in the signature also made MockK's reflection over the activity throw NoClassDefFoundError on API 33, crashing PaywallActivityInsetsTest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Use a strict container mock in PaywallActivityInsetsTest The test attaches its container mock as the process-wide SuperwallGraph. When an earlier test in the same process has configured the real SDK (CI shards run TrackingLogicTest first), that SDK's background work and the Logger reach the mock. Relaxed answers there mock enums and collections, which makes MockK hook Enum and AbstractCollection for the whole process; the next Enum.toString() in the activity lifecycle then overflows the stack and crashes the test run. The container mock is now strict and stubs only what the activity and Logger read: the view registry, the options and the delegate adapter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update coverage badge [skip ci] --------- Co-authored-by: Ian Rumac <ian@superwall.com> Co-authored-by: Ian Rumac <ian.rumac@outlook.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Update coverage badge [skip ci] * Clear fullscreen WebView margins when a cached paywall is reused in a container style A paywall first shown fullscreen keeps its WebView's inset listener and side/bottom margins. Reusing it as a modal, drawer or popup left those margins in place on top of the container's padding. Container styles now remove the listener and reset the margins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Adedeji Toki <deji@fourfoursoftware.com> Co-authored-by: Brian Anglin <brianranglin@gmail.com> Co-authored-by: Kanna <noreply@kanna.sh> Co-authored-by: Leonardo Bilck <leonardo@plotkids.com> Co-authored-by: Yusuf Tör <yusuf@yusuftor.co.uk> Co-authored-by: Yusuf Tör <3296904+yusuftor@users.noreply.github.com> Co-authored-by: Makisuo <31933546+Makisuo@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a paywall is embedded with
PaywallComposablein the app's own Activity, trial reminders are never scheduled. A customer hit this on 2.8.4 and 2.8.8 with a customPurchaseController, and it is still the case in 2.9.0.Three paths schedule notifications, and all of them go through
PaywallNotifications.forPaywall:DependencyContainer.notifyOfTransactionCompleteScheduleNotificationmessage inSuperwall.eventDidOccurDependencyContainer.scheduleTrialNotificationsforPaywallonly knew aboutSuperwallPaywallActivityandInWindowPaywallHost. For an embedded view it returned null, and the SDK loggedNo paywall host available to schedule fallback notificationswithout scheduling anything.Change
forPaywallgets a third case: the Activity the view is embedded in, whichPaywallBuilder.activity(...)sets onencapsulatingActivity. A finishing or destroyed Activity doesn't count. The current-activity fallback still only stands in for aSuperwallPaywallActivity, so a paywall that isn't embedded anywhere still gets nothing.PaywallNotifications.forEmbeddingActivityasks forPOST_NOTIFICATIONSthroughrequestPermissionFrom, the helperInWindowPaywallHostalready uses. That means the activity result registry on aComponentActivity(Compose apps) and the headlessPermissionRequestActivityon a plainActivity.PaywallViewholds the instance, so repeated lookups reuse it and only one permission waiter is kept. It is deliberately not released incleanup():PaywallComposablereleases the view right after a purchase, often while the permission prompt is still on screen, and a grant that comes after that should still schedule the reminders. A pending request is released when a lifecycle-aware host Activity is destroyed, because its answer can no longer arrive.Tests
The new
EmbeddedPaywallNotificationsTestuses Robolectric with aComponentActivity:cleanup()still schedulesNot verified
paywallManager.currentViewpoints at an embedded view during web checkout, which the web-checkout path relies on. The other two paths take the view directly.PaywallComposable.🌸 Shipped with Kanna — an open-source workspace for all your coding agents. Written by
claude/opus.