Upgrade: Verify the purchase state before starting a Play checkout - #68
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
PurchaseRefreshcarries 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.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).BillingConnection/BillingManagerthrough a scriptableFakeBillingClient(amply has no mockk); it reads the queried product type viaQueryPurchasesParams.zza(), the only accessor Play exposes — a billing-library bump could break that loudly at compile time.