Skip to content

2.8.5 - #476

Merged
ianrumac merged 44 commits into
mainfrom
develop
Oct 9, 2026
Merged

2.8.5#476
ianrumac merged 44 commits into
mainfrom
develop

Conversation

@ianrumac

@ianrumac ianrumac commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator

2.8.5

Enhancements

  • Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with Superwall.instance.presentCustomerCenter(), dismiss it with Superwall.instance.dismissCustomerCenter(), and configure it with SuperwallOptions.customerCenter.
  • Adds CustomerCenterDelegate and the customerCenter_open, customerCenter_close, customerCenter_action, customerCenter_surveyResponse and customerCenter_refundRequest events.

Fixes

  • Fix subscribers with an unexpired subscription being reported as inactive when Google Play fails to answer a purchase query, for example when the billing client isn't ready at launch. A query that succeeds and reports no purchases still deactivates straight away.
  • Fix subscribers being reported as inactive when Google Play returns an active purchase whose product no longer maps to an entitlement in config. A lost mapping is no longer treated as the subscription ending.
  • Fix purchases being refunded by Google Play for not being acknowledged when they did not complete through the billing flow callback, for example a pending purchase that settled while the app was closed, the app being killed mid-purchase, or a Play Store promo code. The AutomaticPurchaseController now acknowledges any unacknowledged purchase whenever it syncs the subscription status, including on launch, and retries acknowledgements that fail.
  • Fix register() never calling its handler, and every later placement stalling behind it, when a Google Play product query at launch was dropped. A request that found the billing client disconnected between being queued and running was discarded without a callback, leaving its products loading forever. Such requests are now re-queued until the client reconnects, a product query that Google Play never answers times out after 15 seconds, a disconnect with queued requests reconnects, and a second startConnection is no longer issued while one is in flight.
  • A paywall whose webview can't load, because every URL and retry has been used up or paywalls.timeoutAfter passed, is now dismissed as declined with the webViewFailedToLoad close reason, matching iOS. The register handler gets onDismiss and then the feature block for a non-gated placement, or onError for a gated one. Previously the paywall stayed on screen, and with timeoutAfter set the handler received onPresent followed by a bare onError while the paywall was still showing. A paywall that fails to load off-screen, for example during preload, reloads the next time it is presented.

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run ktlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

ianrumac and others added 30 commits September 30, 2026 16:43
GoogleBillingWrapper now remembers when billing is unavailable and fails
requests straight away instead of reconnecting for each one. A billing
client that can't be created marks billing unavailable rather than leaving
queued requests hanging, and availability is probed again whenever the app
returns to the foreground. BillingNotAvailable is no longer cached per
product for the life of the process.

StoreManager applies a single rule when billing is unavailable: a paywall
presents with whatever resolved without Play (test, custom and substitute
products) and only fails when nothing resolved and test mode is off.

AutomaticPurchaseController no longer throws from its constructor when the
billing client can't be created, and purchase() fails instead of waiting
forever when the connection can't be established.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test mode modal and the purchase and restore drawers put a rounded
background on their content, but BottomSheetDialog wraps it in a sheet
container with its own opaque background, whose square corners showed
behind the rounded ones. Make that container transparent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a "Configure with test mode" option to the purchase controller test
screen and a Maestro flow that configures in test mode, presents a Play
paywall from the test catalog and completes a simulated purchase. The flow
needs the Play Store disabled on the device, so it isn't part of the default
flows in config.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Log a warning listing the products a paywall presents without when
  billing is unavailable, so a missing price can be traced from logs.
- Make BillingAvailability and GoogleBillingWrapper.availability internal.
- Clarify that only setup-time unavailability is remembered.
- Clear the test mode sheet container's background after its first
  layout, since BottomSheetBehavior swaps in its own background then on
  Material-themed hosts.
- Rename the instrumented wrapper tests that still described the removed
  per-product failure cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On devices with a broken Play Store (e.g. microG-based emulators), billing
setup keeps returning ERROR instead of BILLING_UNAVAILABLE. That was
retried forever, so product requests queued behind the connection and a
paywall load never finished. After three transient setup failures in a
row, billing is now marked unavailable so waiting requests fail. Reconnect
attempts carry on and a later successful setup makes billing available
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A paywall that presented without some Play products because billing was
unavailable was cached with failAt unset, so PaywallRequestManager never
reloaded its products. Once billing recovered (after the foreground
re-probe, or a reconnect following transient setup failures), the paywall
still showed without Play prices for the rest of the session. Set failAt
for a partial load so the next request retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the SDK working on devices without Play Billing
Migrates PaywallViewCache and TestMode onto the StateActor primitives:
PaywallCacheState/PaywallCacheContext own the view map and active key,
TestModeState gains pure reducers and async actions with TestModeContext
and TestModeLogic, and ConfigState moves up to the config package.

Rebased onto develop (Sep 2026) with these adaptations:
- Keep develop's loadingColor on PaywallViewCache/LoadingView.
- Port develop's productsLoaded/awaitTestProducts so StoreManager can wait
  for the test product catalog; the deferred is carried through session copies.
- SetActive preserves an existing session and only clears entitlement
  selections when the activation reason changes, matching develop.
- TestMode.activate is suspend and awaited via `immediate`; ConfigState
  launches it in its scope so the modal never blocks config.
- Cache view factories no longer hop to Dispatchers.Main: acquire* block the
  caller (usually main) with runBlocking, so that hop deadlocked.
- Keep develop's ensureActive guard in PaywallMessageHandler.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ports the entitlements slice of the March actor draft (ir/refactor/actors)
onto current develop:

- EntitlementsState holds status, product entitlements, device, backing and
  web entitlements as an immutable snapshot with pure reducers; `all`,
  `active` and `inactive` are derived from it. createInitialEntitlementsState
  rebuilds the snapshot from storage before the actor starts, including
  web entitlements from the latest redemption response.
- Entitlements becomes a facade over a StateActor implementing
  EntitlementsContext. Status changes and product entitlement updates are
  persisted immediately instead of through a collected flow.
- Web entitlements are cached in state rather than re-read from storage on
  every access, so WebPaywallRedeemer now publishes them through a new
  Factory.setWebEntitlements hook at each point it writes the redemption
  response.
- Adds EntitlementsRefactorSafetyTest (46 cases) and reworks EntitlementsTest
  for the actor construction.

Differences from the draft: the constructor keeps `Entitlements(storage)`
working via defaults, and EntitlementsContext no longer carries
HasExternalPurchaseControllerFactory since no action used it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Acquire loading/shimmer views through the cache in
  SuperwallPaywallActivity so getPaywall() + startWithView() no longer
  crashes when they have not been created yet.
- Route external ViewStorage writes (activity launch/restore, DebugView)
  through new synchronous PaywallViewCache.storeView/removeView so cache
  state and ViewStorage cannot diverge; RemoveAllExceptActive now removes
  exactly the keys it evicted.
- Run the cache actor on the container ioScope instead of a leaked scope.
- Only publish polled web entitlements when they are persisted.
- Tests: cover external writers and lazy loading/shimmer, assert the
  redeemer publishes what it persists, tighten weak assertions, drop
  obsolete delays, and extract a makeEntitlements helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SuperwallPaywallActivity and DebugView now reach paywall views through an
internal PaywallViewRegistry obtained from
DependencyContainer.makeViewRegistry(), instead of reaching into
paywallManager.cache or ViewStorage directly. Writes go through the cache
so it and ViewStorage stay in sync; reads use ViewStorage, which survives
Activity recreation. PaywallManager's cache is private again.

Adds an on-device test proving the loading and shimmer views can be built
on a thread without a Looper, as the cache actor does, and still draw and
animate on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createInitialEntitlementsState replayed the saved status before the stored
product entitlements. AddProductEntitlements replaces allTracked, so
status entitlements not tied to a product dropped out of `all` while
still in `active`, until the next status update. The old startup code
never replaced that set. Restore product entitlements first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AddProductEntitlements overwrote allTracked, so a status-only entitlement
dropped out of `all` once product entitlements loaded while still showing
in `active`. Also rename the off-main cache acquire test to match what it
asserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… override

- Take the delegate's previous status from the status flow. Entitlements
  now persists before the listener runs, so reading storage gave from == to.
- AddProductEntitlements no longer writes allTracked; `all` already unions
  product entitlements, and this avoids stale entries on a remapped product.
- Add SubscriptionStatusDelegateOverrideTest for setting the status from
  inside subscriptionStatusDidChange (add, remove, replace, grant).
- Stub the suspend PaywallViewCache.save with coEvery in
  PaywallManagerExperimentIsolationTest so unit tests compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Actor refactor: PaywallViewCache, TestMode and Entitlements
An empty Play Billing read set the status to inactive no matter why it
was empty. The queries can fail outright - the billing client is not
ready at launch, the query times out, the retries run out - and the
code published inactive first and only then scheduled its retry, so a
paying subscriber lost access for at least a second. An active purchase
that config no longer maps to an entitlement did the same.

Nothing that is not an answer may now demote a subscriber whose
entitlement has not expired. A read that succeeds and reports no
purchases is still an answer and deactivates straight away, so refunds
and cancellations behave as before.

The status carries config-shaped entitlements, which have no expiry
date, so the dates come from the device CustomerInfo that ReceiptManager
builds from Play receipts and persists across launches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Match refuting product ids across namespaces: backfill productIds from
  the device records and compare on the subscription id, so config-shaped
  entitlements and full config ids line up with raw Play ids. An
  entitlement with no known product ids is never refuted.
- Keep web entitlements out of activeDeviceEntitlements so
  WebPaywallRedeemer.clear() and the restore message stay Play-only.
- Don't carry entitlements flagged inactive into the published Active.
- Document the null-expiry limitation and the device-clock trade-off.
- Log the swallowed storage error.
- Add tests built from config-shaped entitlements and real id shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep subscribers active through a Play read that answered nothing
Purchases were only acknowledged from the billing flow callback, with no
retry. Pending purchases that settled later, purchases interrupted by
process death, Play Store promo codes and failed acknowledgements were
never acknowledged and got refunded by Play after 3 days.

The subscription status sync now acknowledges any purchased, unacknowledged
purchase it reads, and acknowledgements wait for a ready client and retry.
In-flight tokens are tracked so the callback and the sync don't both
acknowledge the same purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Acknowledge unacknowledged purchases on every status sync
Port of the iOS SDK's Customer Center (superwall/Superwall-iOS#509): a
self-service screen where users can view and restore purchases, cancel or
change a Google Play subscription, request a refund, manage a web
subscription and contact support.

- Superwall.presentCustomerCenter()/dismissCustomerCenter(), configured
  via SuperwallOptions.customerCenter or per presentation
- CustomerCenterDelegate and customerCenter_* events
- Store-specific actions mapped to Google Play: subscription page for
  cancel/change plan, order history for refunds
- Web catalogue fill-in for Stripe/Paddle products, cached for 5 minutes
- Strings localized in the same 45 languages as iOS
- restore can now suppress the SDK's own failure prompts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PurFQ7fxGjSF6y6ojfrd81
claude and others added 14 commits October 7, 2026 17:02
Like iOS, where the Customer Center inherits the app's tint, it now takes
the colorPrimary of the theme it was presented from (else the
application's) instead of Material 3's default purple. A configured
accent still wins, and system blue fills in when the app's theme only
carries framework or Material/AppCompat defaults. Spinners and dialog
buttons are tinted too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PurFQ7fxGjSF6y6ojfrd81
From superwall/Superwall-iOS#533, the fixes that apply on Android:

- A sheet leaves with the detail screen that asked for it. When a
  purchase's detail screen goes, say because a refresh removed the
  purchase, its survey is dropped along with the pending action, so
  answering it can't act on a purchase the customer no longer sees. A
  sheet whose action finishes after its screen has gone isn't shown over
  the root. Root sheets stay while a detail screen covers them.
- With no product id or package, Google Play's subscription link opens
  the list of subscriptions instead of a deep link to no subscription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Translate the App Store store label in every locale, and fail the
  strings test when a translation is missing an English key
- Don't list an entitlement twice when its only link to a listed
  purchase is its latest product
- Keep the rows busy while a survey gates an action, and until that
  action finishes, so a survey-gated restore can't run twice
- Run the view model on the SDK's MainScope, whose exception handler
  keeps an unexpected error from crashing the host app
- End a presentation whose activity never starts, such as one blocked
  from the background, so later presentations aren't refused as already
  presented
- Fold the presentsFailureAlert KDoc into the existing block and drop the
  empty branch around the restore failure prompts
- Use relative padding where the start and end differ, document why
  USER_CANCELLED stays, and sort the new imports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A restore that throws, say on a Play billing error, now ends on the
  dismissible nothing-found alert instead of leaving the restoring
  overlay up for good
- Coming back from a web management page reloads web entitlements from
  Superwall, so a cancelled or changed Stripe/Paddle subscription shows
  without waiting up to a day for the next poll. The redeemer's poll
  body is now refreshWebEntitlements(), which both use
- Test the Customer Center manager: a presentation whose activity never
  starts ends after the attach timeout, one that attached is left alone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Read the locale through ConfigurationCompat. Configuration.getLocales()
  only exists from API 24, so presenting the Customer Center crashed on
  Android 6, which the SDK still supports
- Run the live adapters' network and billing work on the container's
  IOScope, as the rest of the SDK does: product lookup (through
  StoreManager rather than Superwall.getProducts), the catalogue fetch,
  the purchase refresh and restore. The view model runs on the main
  thread, so the catalogue request threw NetworkOnMainThreadException
  and web products never got their details
- refreshWebEntitlements uses the redeemer's IOScope too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Running the live adapters on IOScope's full context swapped the caller's
job for IOScope's SupervisorJob, so the work stopped being a child of
its caller. The catalogue fetch's 5 second timeout couldn't cancel it,
and offline its retry waits kept web purchase cards on placeholders for
about a minute. Closing the Customer Center didn't reach it either.

The adapters now take IOScope's dispatcher and error handling without
its job. refreshWebEntitlements is back to a plain extraction of the
poll body, with the Customer Center moving it off the main thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestMode tracks each event from its own coroutine on the IO pool, so the
Open and Close events could land after activate returned, out of order,
or into the list from two threads at once. The test now receives each
event from a channel: Open while the modal is showing, Close after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…b8dtzb

Add Customer Center feature for subscription management
…view can't load

register() could stall for the life of the process when a Google Play product
query at launch was dropped: a request that found the billing client disconnected
between being queued and running was discarded without a callback, so its products
stayed Loading and every paywall request, plus every later register() behind it in
the serial queue, waited forever. Such requests are now re-queued until the client
reconnects, a query Play never answers times out after 15s, a disconnect with queued
requests reconnects, and no second startConnection is issued while one is in flight.

A paywall whose webview can't load, or whose paywalls.timeoutAfter passes, is now
dismissed as declined with the webViewFailedToLoad close reason, matching iOS. The
register handler gets onDismiss followed by the feature block for a non-gated
placement or onError for a gated one, instead of a paywall left on screen (and, with
timeoutAfter, an onPresent followed by a bare onError). A paywall that fails to load
off-screen reloads the next time it is presented.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e setup answers

awaitGetProducts now has a 15s timeout, so advancing virtual time to idle while a
request is queued fails it with Timeout before the test delivers the simulated
setup result. Run the queued request with runCurrent instead, the same change as
TestModeBillingUnavailableIntegrationTest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-error

Fail product loads instead of hanging, and dismiss paywalls whose webview can't load
@ianrumac
ianrumac merged commit f70cb31 into main Oct 9, 2026
21 of 22 checks passed

This branch was successfully deployed

1 active deployment
github-pages — 86881e97 Deployed Oct 8, 2026 by ianrumac via deploy #167
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.

3 participants