Skip to content

Keep Google from refunding purchases when the app is closed too soon - #85

Merged
d4rken merged 1 commit into
mainfrom
fix/purchase-ack-safety-net
Aug 18, 2026
Merged

Keep Google from refunding purchases when the app is closed too soon#85
d4rken merged 1 commit into
mainfrom
fix/purchase-ack-safety-net

Conversation

@d4rken

@d4rken d4rken commented Aug 18, 2026

Copy link
Copy Markdown
Member

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

  • SEMANTIC port of General: Keep Google from refunding purchases when the app is closed too soon sdmaid-se#2685 at 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.
  • Wiring amply gains: androidx.hilt:hilt-work + 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 stays a plain worker on purpose: HiltWorkerFactory delegates workers it doesn't know to WorkManager's default reflective factory.
  • Two separate unique-work identities, …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.
  • The sweep is self-completing: nothing cancels the work from the foreground, because an ack pass can legitimately see zero unacknowledged purchases while the Play sheet is still open. The worker decides success/retry from 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.
  • Closest review points: the ack pass now runs under a mutex and returns per-outcome counts (the worker sweep and the reactive collector would otherwise race the token bookkeeping); PurchaseAckScheduler takes a Provider<WorkManager> because AmplyApp eagerly injects UpgradeSurfaceSync and with it the whole billing stack during Application field injection; and the arming call site inside UpgradeRepoGplay.launchBillingFlowInternal sits inside the busy-guard try so a failed arm still releases the guard.

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.
@github-actions github-actions Bot added Google Play Google Play flavor / distribution Build/Deploy Build system / CI / release tooling enhancement New feature or request labels Aug 18, 2026
@d4rken d4rken added bug Something isn't working and removed enhancement New feature or request labels Aug 18, 2026
@d4rken
d4rken marked this pull request as ready for review August 18, 2026 15:55
@d4rken
d4rken merged commit ba6ae08 into main Aug 18, 2026
12 checks passed
@d4rken
d4rken deleted the fix/purchase-ack-safety-net branch August 18, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working Build/Deploy Build system / CI / release tooling Google Play Google Play flavor / distribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant