Skip to content

Schedule notifications for paywalls embedded in the app's Activity - #495

Merged
ianrumac merged 6 commits into
developfrom
fix/embedded-paywall-notifications
Nov 5, 2026
Merged

ianrumac merged 6 commits into
developfrom
fix/embedded-paywall-notifications

Conversation

@anglinb

@anglinb anglinb commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Problem

When a paywall is embedded with PaywallComposable in the app's own Activity, trial reminders are never scheduled. A customer hit this on 2.8.4 and 2.8.8 with a custom PurchaseController, and it is still the case in 2.9.0.

Three paths schedule notifications, and all of them go through PaywallNotifications.forPaywall:

  • the fallback trial reminder in DependencyContainer.notifyOfTransactionComplete
  • the paywall's ScheduleNotification message in Superwall.eventDidOccur
  • web checkout reminders in DependencyContainer.scheduleTrialNotifications

forPaywall only knew about SuperwallPaywallActivity and InWindowPaywallHost. For an embedded view it returned null, and the SDK logged No paywall host available to schedule fallback notifications without scheduling anything.

Change

  • forPaywall gets a third case: the Activity the view is embedded in, which PaywallBuilder.activity(...) sets on encapsulatingActivity. A finishing or destroyed Activity doesn't count. The current-activity fallback still only stands in for a SuperwallPaywallActivity, so a paywall that isn't embedded anywhere still gets nothing.
  • PaywallNotifications.forEmbeddingActivity asks for POST_NOTIFICATIONS through requestPermissionFrom, the helper InWindowPaywallHost already uses. That means the activity result registry on a ComponentActivity (Compose apps) and the headless PermissionRequestActivity on a plain Activity.
  • PaywallView holds the instance, so repeated lookups reuse it and only one permission waiter is kept. It is deliberately not released in cleanup(): PaywallComposable releases 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.
  • An in-window host that is closing returns null as before, rather than falling through to the embedded case.

Tests

The new EmbeddedPaywallNotificationsTest uses Robolectric with a ComponentActivity:

  • an embedded paywall's notifications are scheduled, and later lookups reuse the same instance
  • the permission is requested through the registry, and a grant that arrives after cleanup() still schedules
  • a request still pending when the Activity is destroyed is released, and nothing is scheduled
  • a paywall that isn't embedded anywhere gets no notifications from the current-activity fallback
./gradlew :superwall:testDebugUnitTest --tests '*EmbeddedPaywallNotificationsTest' --tests '*InWindowPaywallHostTest' --tests '*InWindowComponentActivityHostTest'
EmbeddedPaywallNotificationsTest   tests=4  failures=0
InWindowComponentActivityHostTest  tests=3  failures=0
InWindowPaywallHostTest            tests=19 failures=0

Not verified

  • I haven't checked whether paywallManager.currentView points at an embedded view during web checkout, which the web-checkout path relies on. The other two paths take the view directly.
  • I haven't tested on a device with PaywallComposable.

🌸 Shipped with Kanna — an open-source workspace for all your coding agents. Written by claude/opus.

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
ianrumac force-pushed the fix/embedded-paywall-notifications branch from a77f9a6 to 3f3f852 Compare November 5, 2026 15:05
@ianrumac
ianrumac marked this pull request as ready for review November 5, 2026 16:11
@maple-review-bot

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>
@maple-review-bot

This comment has been minimized.

@maple-review-bot

maple-review-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

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.
no findings · tests covered · risk medium · 1/1 new units observable

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.

b4b39b6 · What was checked, coverage and telemetry details are on the Maple / review check. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@ianrumac
ianrumac merged commit 2862d30 into develop Nov 5, 2026
7 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants