Keep Google from refunding purchases when the app is closed too soon - #85
Merged
Conversation
Play auto-refunds (and revokes) purchases not acknowledged within 3 days. The in-process ack machinery covers every case where the process lives long enough; what it cannot cover is a process death around the Play sheet (aggressive OEM task killers) followed by the user not reopening the app before the deadline. Add a gplay-only WorkManager safety net: - PurchaseAckWorker: self-completing sweep via a new bounded BillingManager.ensureAllAcknowledged() that refreshes and acknowledges in the same coroutine (the reactive ack collector is async, so a worker cannot prove its acks happened through it). Retries with exponential backoff until the purchase's refund deadline, then gives up visibly. - PurchaseAckScheduler: two unique work identities. A launch watch (REPLACE, armed and awaited before startIapFlow with a 30min delay so it cannot complete while the user is still in the sheet) and a discovered-purchase rescue (KEEP, 1min delay, armed directly from an ack pass that finds unacknowledged purchases, pre-attempt). Separate identities so a new purchase flow can never displace a pending rescue. Both triggers are fail-open: a broken WorkManager never blocks a purchase or an ack. WorkManager resolves via Provider at first arm, because AmplyApp eagerly injects UpgradeSurfaceSync (and with it the whole billing stack) during Application field injection, where resolving WorkManager would trigger its on-demand initialization before the worker factory field is set. - Nothing cancels the work from the foreground path: an ack pass can see zero unacked purchases while the sheet is still open, so the worker completes itself after its own reconciliation instead. The ack pass now runs under a mutex (the worker sweep and the reactive collector would otherwise race the token bookkeeping) and reports per-outcome counts for the sweep result mapping. New wiring this needs: androidx.hilt:hilt-work plus its KSP compiler, AmplyApp implements Configuration.Provider with an injected HiltWorkerFactory, the androidx.startup WorkManagerInitializer is removed from the manifest so that configuration is actually used, and WorkManager becomes injectable (WorkManagerModule). SettleRefreshWorker is deliberately left as-is: HiltWorkerFactory delegates workers it doesn't know to WorkManager's default reflective factory. This is a semantic port of d4rken-org/sdmaid-se#2685 — the behaviour is the same, the shape follows amply's own billing architecture (a BillingManager that owns its scope, pending purchases filtered at the call site, hand-written fakes instead of a mocking framework). FOSS stays untouched: all new billing types live in src/gplay, workers need no manifest entry, and the HiltWorkerFactory resolves the worker only in gplay variants.
d4rken
marked this pull request as ready for review
August 18, 2026 15:55
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
Google refunds and revokes a Play purchase that is not confirmed back to Google within 3 days. Amply confirms it as soon as it sees the purchase, but if the app gets killed right around the purchase (some phones kill background apps aggressively) and is never reopened before the deadline, the confirmation never happens: the user pays, then quietly loses the upgrade.
The Play build now runs that confirmation in the background too, even if the app is never opened again. It re-tries over the whole 3-day window and stops on its own once everything is confirmed. Nothing changes for the FOSS build.
Technical Context
f6f5c5e03. Amply's billing stack is a different shape (BillingManager owns its own scope instead of taking an injected one, pending purchases are filtered at the ack-pass call site, tests use hand-written fakes rather than a mocking framework), so this is behaviour parity, not file parity.androidx.hilt:hilt-work+ its KSP compiler,AmplyAppimplementsConfiguration.Providerwith an injectedHiltWorkerFactory, theandroidx.startupWorkManagerInitializeris removed from the manifest so that configuration is actually used, andWorkManagerbecomes injectable (WorkManagerModule).SettleRefreshWorkerstays a plain worker on purpose:HiltWorkerFactorydelegates workers it doesn't know to WorkManager's default reflective factory.…purchase-ack.launch.v1(REPLACE, 30min delay, armed and awaited before the Play sheet opens) and…purchase-ack.rescue.v1(KEEP, 1min delay, armed pre-attempt whenever an ack pass finds unacknowledged purchases). Separate so a new purchase flow can never displace a rescue armed for a purchase that already exists. Both arms are fail-open — a broken WorkManager must never block a purchase or an ack.BillingManager.ensureAllAcknowledged(), which refreshes AND acknowledges in the same coroutine — the reactive ack collector is asynchronous, so a worker cannot prove through it that its acks happened.PurchaseAckSchedulertakes aProvider<WorkManager>becauseAmplyAppeagerly injectsUpgradeSurfaceSyncand with it the whole billing stack during Application field injection; and the arming call site insideUpgradeRepoGplay.launchBillingFlowInternalsits inside the busy-guardtryso a failed arm still releases the guard.