Skip to content

Upgrade: Verify the purchase state before starting a Play checkout - #68

Merged
d4rken merged 3 commits into
mainfrom
fix/pending-purchase-gates
Aug 16, 2026
Merged

Upgrade: Verify the purchase state before starting a Play checkout#68
d4rken merged 3 commits into
mainfrom
fix/pending-purchase-gates

Conversation

@d4rken

@d4rken d4rken commented Aug 16, 2026

Copy link
Copy Markdown
Member

What changed

Both purchase buttons now verify the account state with Google Play at tap time before opening the checkout: a payment Play is still processing, an existing Pro purchase made on another device, or a subscription still set to renew stops the launch instead of charging twice. The subscription button previously started the checkout without any verification. If the check cannot complete, the purchase does not start. Retrying a product whose payment is still pending now gets the pending explanation instead of a misleading "already owned" error, and a user waiting on a pending payment keeps seeing that explanation even if price loading fails or the entitlement is briefly bridged by the grace window.

Technical Context

  • Closes the audited gaps against the canonical sdmaid-se implementation (General: Show when a Google Play payment is still being processed sdmaid-se#2653 + the gate corrections in sdmaid-se PR #2662); amply's existing pending-purchase support from Upgrade: Offer an optional Pro upgrade for history, widget, and tile #57 (entitlement split, button locks, restore hint, inline pending note) is untouched.
  • Adopts the canonical reconciliation shape in full (user decision): PurchaseRefresh carries provenance (confirmed set, commit-time occurredAt, partialError), a refresh that finds only a pending known-Pro purchase now publishes instead of failing the connection, and the partial failure feeds the grace episode clock centrally. Amply's previous throw-on-partial contract and its pinning test were rewritten accordingly.
  • The strict gate (refreshStrict) fails closed and invalidates a connection that died mid-check; the regression test survives a mutation check (removing the invalidation call makes it fail).
  • The renewal guard reads RAW purchases, not mapped SKUs, so a renewing subscription with an unknown product ID still blocks a double purchase.
  • Tests drive the real BillingConnection/BillingManager through a scriptable FakeBillingClient (amply has no mockk); it reads the queried product type via QueryPurchasesParams.zza(), the only accessor Play exposes — a billing-library bump could break that loudly at compile time.

d4rken added 3 commits August 16, 2026 16:03
A pending payment can exist at Play while local state lags (Play reports
PENDING only to the buying device), and a purchase started against that
state either gets rejected as already owned or charges the user twice for
the same entitlement. Both purchase paths now run the same pre-purchase
gate, and the refresh they run on carries enough provenance to fail closed.

- BillingConnection.PurchaseRefresh gains provenance (confirmed set,
  hasConfirmedProPurchase, commit-time occurredAt, partialError) instead of
  just the merged view plus isComplete.
- combinePurchaseResults takes the sku-type resolver: a PENDING result only
  suppresses the couldn't-verify error when it maps to a known upgrade SKU.
  An unknown pending product proves nothing about the type whose query
  failed. provesAbsence now ignores a surviving PENDING overlay entry, so a
  payment in progress can't freeze the unconfirmed-episode clock.
- BillingManager gains processReconciliation(), run after the connect
  loop's initial refresh and by refresh(): it re-signals dead-binder
  invalidation (a partial refresh returns instead of throwing, so it no
  longer reaches useConnection's detection) and feeds the grace episode
  clock with the refresh's COMMIT time, so a confirmation landing in
  between stays newer.
- BillingConnection.querySubscriptions / BillingManager.querySubscriptions
  are replaced by refreshStrict(): the gate needs both product types and
  the pending state, and still fails closed on anything short of a complete
  round-trip. UpgradeRepoGplay exposes it as verifyPurchaseStateNow().
- UpgradeViewModel.runPurchaseGate() is shared by onGoIap() and
  startSubPurchase(): error surfaces, timeout answers PurchaseCheckFailed,
  a pending payment answers PurchasePending, and nothing launches in any of
  those cases. After a clear gate the one-time path blocks on
  Info.hasAutoRenewingSubscription (RAW purchases, so an unknown or legacy
  renewing product still blocks), and the subscription path blocks on fresh
  mapped upgrades (a stale screen must not buy Pro twice) and then on the
  same renewal check.
- A PendingPurchaseBillingException from an already-owned recovery that
  finds a pending payment maps to the pending dialog: restore tips are the
  wrong advice there, nothing needs restoring.
- The grace branch carries billingData through, so a pending payment stays
  visible while the upgrade runs on grace, and priceIndependent covers a
  pending payment so a price-query failure can't replace that explanation
  with an error screen.
- SubscriptionCheckFailed becomes PurchaseCheckFailed (both paths use it);
  its string is replaced by upgrade_screen_purchase_check_failed_message,
  and the pending dialog gets upgrade_screen_pending_dialog_message.
FakeBillingClient is the new seam: a scriptable stand-in for Play's client
lets the tests drive the REAL BillingConnection and BillingManager (reducer,
full-snapshot decision, partial-failure provenance, connect loop) instead of
asserting against a hand-written imitation. It answers the two per-type
queries independently and can deliver a purchase event from inside a query,
which is the only way an overlay entry survives that query's commit.

- BillingConnectionRefreshTest: what a refresh reports about itself
  (confirmed set, hasConfirmedProPurchase, isComplete, mapped partialError),
  and that a surviving PENDING overlay still proves absence while an owned
  one does not.
- BillingManagerTest: refreshStrict fails closed on an incomplete refresh
  and returns the split data on a complete one; a partial reconciliation
  without a confirmed purchase signals its commit time while one that
  confirmed a purchase stays quiet; an invalidating partial refresh tears
  the connection down; a pending purchase is never acknowledged while an
  unacknowledged owned one still is.
- UpgradeRepoGplayFlowTest: the strict lookup fails closed even for a recent
  owner, reports the fresh split state, an already-owned recovery over a
  pending payment reports PendingPurchaseBillingException, and a pending
  payment stays visible while grace keeps the upgrade.
- UpgradeRepoGplayInfoTest: hasAutoRenewingSubscription sees an unknown
  renewing product and ignores one-time and pending purchases.
- GplayUpgradeViewModelTest: a pending payment keeps its hint (and its
  locked offers) when both price queries fail.
refreshStrict() throws its incomplete-result error AFTER useConnection
already returned: refreshPurchases hands back a partial result instead of
throwing, so useConnection's dead-binder detection never sees it. A gate
that ran against a connection whose binder died mid-query (partialError
cause chain carrying SERVICE_DISCONNECTED / SERVICE_TIMEOUT) left that
connection installed, and every later purchase check kept talking to the
corpse until something else tore it down.

processReconciliation() already compensated for the refresh()/connect-loop
paths; the strict path had no such call. Extract that dead-connection block
into invalidateOnDeadConnection() and call it from both. The strict path
deliberately does NOT feed the episode clock — a gate the user aborted
mid-purchase is not a reconciliation outcome.

Fixes review finding F1.
@github-actions github-actions Bot added Google Play Google Play flavor / distribution enhancement New feature or request labels Aug 16, 2026
@d4rken
d4rken merged commit 8c22be3 into main Aug 16, 2026
12 checks passed
@d4rken
d4rken deleted the fix/pending-purchase-gates branch August 16, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Google Play Google Play flavor / distribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant