Skip to content

feat: report FCM push tokens for Superwall notifications - #494

Merged
ianrumac merged 6 commits into
developfrom
feat/fcm-notification-tokens
Nov 5, 2026
Merged

ianrumac merged 6 commits into
developfrom
feat/fcm-notification-tokens

Conversation

@anglinb

@anglinb anglinb commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

What it does

Adds FCM push-token reporting, so Superwall can send server-side push notifications to Android devices. It is the Android counterpart of superwall/Superwall-iOS#571.

New public API on Superwall. All of it is callable from Java. The SDK has no Firebase dependency and never requests permission itself.

fun setPushToken(token: String)                    // from FirebaseMessagingService.onNewToken, and FirebaseMessaging.getInstance().token at launch
fun optOutOfNotifications()
fun optInToNotifications()
fun handleNotificationOpen(intent: Intent)         // the tap intent (FCM-displayed notifications carry the data as extras)
fun handleNotificationOpen(data: Map<String, String>)

A new NotificationTokenManager sends a notification_token event through the SDK's normal events queue (POST /api/v1/events). It is not a new endpoint and not a user attribute.

  • When it sends:

    • when the token, the permission (re-checked on every foreground), opt in/out, or the identity changes (identify and reset);
    • otherwise as a heartbeat at most once every 24 hours.

    The SDK persists a SHA-256 fingerprint of the sent state and identity, plus the time it was sent, so nothing is re-sent per session.

  • Flag gating: controlled by the user_notifications static-config toggle (FeatureFlags.userNotifications). With the flag off nothing is sent, but the state is kept in app-specific storage, which survives reset(). When the flag turns on, the state is sent once.

  • Opt-out is persisted per device and survives relaunch, reset() and identify. Opting out sends the token with enabled: false.

  • eventTrackingBehavior == NONE: the manager reads the events queue's own behaviour (EventsQueue.trackingBehavior, now a StateFlow). Nothing is sent or marked as sent while it is NONE, and the manager refreshes once the queue allows tracking again.

  • Permission: AUTHORIZED or DENIED, from POST_NOTIFICATIONS on API 33+ and areNotificationsEnabled() below that.

  • deep_link: an open is only tracked. The SDK doesn't open the link, matching iOS.

Keeping the token private

Both events are a new internal PrivateSuperwallEvent, not a SuperwallEvent. So they:

  • never reach SuperwallDelegate.handleSuperwallEvent or the events flow;
  • can't trigger a paywall;
  • aren't saved to the on-device event store that audience filters read.

They are still sent with $is_standard_event: true.

This PR also fixes two existing log sites that would have leaked the token to SuperwallDelegate.handleLog:

  • A failed /events request logged the whole batch. It now logs only the event count and names.
  • A cache decode failure logged the stored JSON. It now logs only the key and the exception class.

Wire contract

notification_token parameters are unprefixed camelCase, plus the SDK's standard $ keys. The device, alias and user ids are batch headers, as for every event.

{
  "channel": "fcm",
  "address": "fT3x…:APA91b…",
  "permissionStatus": "AUTHORIZED",
  "enabled": true,
  "appVersion": "2.3.4",
  "sdkVersion": "2.9.0"
}

The address is passed exactly as given: FCM tokens are case-sensitive.

notification_open is tracked only when the FCM data has an sw key. FCM data values must be strings, so sw is a JSON string:

{ "sendId": "nsend_…", "contactPointId": "cp_…" }

That is parsed from data sw = "{\"sendId\":\"nsend_…\",\"cpId\":\"cp_…\"}". contactPointId is omitted when cpId is missing.

Server side

superwall/paywall-next has an FCM channel stack:

  • #4808: DB schema;
  • #4822: subs-writer accepts fcm, case-preserving contact point ids;
  • #4809: vault Firebase credentials;
  • then the send API, the sender and the dashboard.

The feature stays dark until the user_notifications flag is on for an app.

Testing

./gradlew :superwall:testDebugUnitTest: 1709 tests, 0 failures, 0 errors, 0 skipped. ktlint 1.5.0 is clean on the changed files.

The new suites have 35 tests:

  • NotificationTokenManagerTest:
    • dedup within and after 24h;
    • re-sends on token, permission, identify, reset and opt changes;
    • opt-out persistence;
    • flag-off caching, a single flush, and the flag turned off again;
    • NONE gating.
  • NotificationTokenLogicTest: sw parsing, the fingerprint and the heartbeat. It also has a wire-contract test that encodes through JsonFactory and EventsRequest, using a token with uppercase letters.
  • NotificationEventTrackingTest: private events are queued, but not passed to the delegate, not stored locally and never a trigger.
  • NotificationTrackingBehaviorTest: NONE → ALL through the real setter, ad-consent actor, EventsQueue and manager.
  • NotificationStorageTest: the real LocalStorage.reset() keeps the token and opt-out state.
  • NotificationOpenIntentTest (Robolectric): intent extras.
  • EventsRequestLogTest: a failed /events request never logs the token.

🤖 Generated with Claude Code

@anglinb

anglinb commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

📚 Docs required

What changed for customers: New public SDK API: Superwall.setPushToken, handleNotificationOpen(intent) / handleNotificationOpen(data), optOutOfNotifications and optInToNotifications, which an Android app must call to receive Superwall notifications.

Why this needs docs: net-new public SDK API (rule 2) with setup steps the app must add (FCM token callback, tap handling). Target pages: content/docs/android/sdk-reference/Superwall.mdx (https://superwall.com/docs/android/sdk-reference/Superwall), a new Android push notifications guide, and content/docs/android/changelog.mdx.

This PR is part of the FCM (Android) channel for Superwall notifications v1. To avoid duplicate docs work, the whole channel has one docs prompt, on superwall/paywall-next#4831: https://github.com/superwall/paywall-next/pull/4831#issuecomment-6512182300. That prompt already covers this PR. Run that one; do not write a separate page from this PR.

Coverage today: absent. No page in superwall/docs mentions Superwall notifications, push tokens or FCM.

Without a docs page this change also gets no changelog entry: superwall/docs publishes a daily GitHub Release that superwall.com renders at /changelog, and every entry must link to a live docs page.

Flagged by the docs-required skill. If this is wrong, remove the label and say why in a
reply so the skill's calibration set can be corrected.

@anglinb anglinb added the docs-required Ships a customer-facing change that needs a superwall/docs update label Oct 30, 2026
anglinb and others added 3 commits November 5, 2026 13:06
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>
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>
@ianrumac
ianrumac force-pushed the feat/fcm-notification-tokens branch from 516a547 to 54380c5 Compare November 5, 2026 15:05
@maple-review-bot

This comment has been minimized.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

@maple-review-bot

This comment has been minimized.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

ianrumac and others added 2 commits November 5, 2026 18:13
…ions

- 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>
Carry the no-values cache read logging into develop's rewritten Cache.readFromDisk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

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

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge. Regression tests cover both prior defects, while notification lifecycle, wire encoding, storage persistence, and payload privacy have dedicated tests.
no findings · tests covered · risk high · 2/2 new units observable

Adds private FCM token registration and notification-open reporting through the existing events queue. The clock-rollback and dropped-registration defects are fixed; no remaining merge-blocking defects were found.

  • NotificationTokenManager persists opt-outs and refreshes registrations on state, identity, and permission changes.
  • PrivateSuperwallEvent keeps notification payloads out of delegates and audience storage.
  • enqueueAccepted reports queue acceptance before registration fingerprints are persisted.

Fixed since the last review

  • ✅ F1 · shouldSend stops heartbeats after a clock correction
  • ✅ F2 · update marks dropped registrations as sent

3f1337a · 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 b35622b 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

docs-required Ships a customer-facing change that needs a superwall/docs update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants