Skip to content

Commit ba6ae08

Browse files
committed
General: Add a persistent acknowledgement safety net for Play purchases
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.
1 parent 9d92b36 commit ba6ae08

16 files changed

Lines changed: 775 additions & 19 deletions

File tree

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ tasks.withType<KotlinCompile>().configureEach {
204204
dependencies {
205205
addBaseKotlin()
206206
addBaseAndroid()
207+
addWorkManager()
207208

208209
addDagger()
209210

app/src/gplay/java/eu/darken/amply/upgrade/core/UpgradeRepoGplay.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import eu.darken.amply.upgrade.core.billing.Sku
1919
import eu.darken.amply.upgrade.core.billing.SkuDetails
2020
import eu.darken.amply.upgrade.core.billing.UserCanceledBillingException
2121
import eu.darken.amply.upgrade.core.billing.client.redacted
22+
import eu.darken.amply.upgrade.core.billing.work.PurchaseAckScheduler
2223
import kotlinx.coroutines.CancellationException
2324
import kotlinx.coroutines.CoroutineScope
2425
import kotlinx.coroutines.Deferred
@@ -53,6 +54,7 @@ import javax.inject.Singleton
5354
class UpgradeRepoGplay @Inject constructor(
5455
private val billingManager: BillingManager,
5556
private val billingCache: BillingCache,
57+
private val ackScheduler: PurchaseAckScheduler,
5658
) : UpgradeRepo {
5759

5860
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -244,6 +246,18 @@ class UpgradeRepoGplay @Inject constructor(
244246
return
245247
}
246248
try {
249+
// Persistent ack safety net, launch trigger: armed and AWAITED before the Play sheet can
250+
// open, so the WorkManager DB transaction lands even if the process dies around the
251+
// sheet — the exact window behind Play's unacknowledged-purchase auto-refunds. Failure
252+
// to arm never blocks the purchase; the foreground ack path still exists.
253+
try {
254+
ackScheduler.armForBillingFlowLaunch()
255+
} catch (e: CancellationException) {
256+
throw e
257+
} catch (e: Exception) {
258+
log(TAG, WARN) { "Failed to arm ack safety net for launch: ${e.asLog()}" }
259+
}
260+
247261
// Bounded, like every other Play path. useConnection waits for a healthy connection
248262
// indefinitely, so a Play outage between rendering the offers and this tap would park the
249263
// launch forever — with launchBusySku still held, which leaves every purchase button busy

app/src/gplay/java/eu/darken/amply/upgrade/core/billing/BillingManager.kt

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import eu.darken.amply.upgrade.core.billing.client.BillingClientException
1515
import eu.darken.amply.upgrade.core.billing.client.BillingConnection
1616
import eu.darken.amply.upgrade.core.billing.client.BillingConnectionProvider
1717
import eu.darken.amply.upgrade.core.billing.client.redacted
18+
import eu.darken.amply.upgrade.core.billing.work.PurchaseAckScheduler
1819
import kotlinx.coroutines.CancellationException
1920
import kotlinx.coroutines.CoroutineScope
2021
import kotlinx.coroutines.Dispatchers
@@ -43,6 +44,8 @@ import kotlinx.coroutines.flow.shareIn
4344
import kotlinx.coroutines.flow.update
4445
import kotlinx.coroutines.flow.updateAndGet
4546
import kotlinx.coroutines.launch
47+
import kotlinx.coroutines.sync.Mutex
48+
import kotlinx.coroutines.sync.withLock
4649
import kotlinx.coroutines.withTimeoutOrNull
4750
import javax.inject.Inject
4851
import javax.inject.Singleton
@@ -55,6 +58,7 @@ import javax.inject.Singleton
5558
@Singleton
5659
open class BillingManager @Inject constructor(
5760
connectionProvider: BillingConnectionProvider,
61+
private val ackScheduler: PurchaseAckScheduler,
5862
) {
5963

6064
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -284,13 +288,18 @@ open class BillingManager @Inject constructor(
284288
// below. The immutable Purchase snapshot keeps reporting isAcknowledged=false until a fresh Play
285289
// query supersedes it, so the ack re-fires every emission until then; re-acking is a documented
286290
// no-op on Play's side, whereas skipping a needed ack gets the purchase auto-refunded after 3
287-
// days. Single sequential collector (the ack pass below), no locking needed.
291+
// days. Confined by [ackMutex] (the collector's pass and explicit sweeps both run under it).
288292
private val loggedAckTokens = mutableSetOf<String>()
289293

290294
// Tokens whose PERMANENT ack failure was already reported. Play will keep rejecting these, so the
291-
// error fires once per token instead of once per pass.
295+
// error fires once per token instead of once per pass. Same [ackMutex] confinement.
292296
private val reportedAckFailures = mutableSetOf<String>()
293297

298+
// Serializes acknowledgement work between the reactive ack collector and explicit
299+
// [ensureAllAcknowledged] sweeps (PurchaseAckWorker): both paths mutate the token bookkeeping
300+
// sets above and both must never double-drive the same purchase's inline retry sequence.
301+
private val ackMutex = Mutex()
302+
294303
// At most one reschedule timer in flight: repeated failures must not stack timers.
295304
private val ackRetryPending = MutableStateFlow(false)
296305

@@ -323,17 +332,36 @@ open class BillingManager @Inject constructor(
323332
// whose immutable snapshot stays false until a fresh Play query.
324333
private enum class AckOutcome { SUCCESS, TRANSIENT, PERMANENT }
325334

335+
/** Aggregate outcome of one ack pass; [ensureAllAcknowledged] maps it to a sweep result. */
336+
data class AckPassOutcome(val transient: Int, val permanent: Int)
337+
326338
// One acknowledgement pass over the canonical purchase list. Never throws except cancellation:
327339
// transient failures schedule a re-drive, permanent ones are reported and left to organic
328340
// fresh-data signals.
329-
private suspend fun runAckPass(purchases: Collection<Purchase>) {
341+
private suspend fun runAckPass(purchases: Collection<Purchase>): AckPassOutcome = ackMutex.withLock {
330342
val needAck = purchases.filter {
331343
val needsAck = !it.isAcknowledged
332344
if (needsAck) log(TAG) { "Needs ACK: ${it.redacted()}" } else log(TAG) { "Already ACK'ed: ${it.redacted()}" }
333345
needsAck
334346
}
335347

348+
if (needAck.isNotEmpty()) {
349+
// Arm the persistent safety net BEFORE attempting anything, and AWAIT the enqueue (the
350+
// scheduler bounds it): the inline retries below can span minutes, and a process death
351+
// inside them must not strand the purchase until Play's 3-day auto-refund. A deferred
352+
// signal (channel + collector) would reintroduce exactly that window. Fail-open: the net
353+
// is an extra layer, never a reason to skip the acks themselves.
354+
try {
355+
ackScheduler.armForUnackedPurchases(needAck.maxOf { it.purchaseTime } + ACK_SAFETY_NET_DEADLINE_MS)
356+
} catch (e: CancellationException) {
357+
throw e
358+
} catch (e: Exception) {
359+
log(TAG, WARN) { "Failed to arm ack safety net: ${e.asLog()}" }
360+
}
361+
}
362+
336363
var transientFailures = 0
364+
var permanentFailures = 0
337365

338366
for (purchase in needAck) {
339367
// First ack of a token is INFO; idempotent repeats drop to DEBUG. This never gates the
@@ -396,6 +424,7 @@ open class BillingManager @Inject constructor(
396424
}
397425

398426
if (outcome == AckOutcome.TRANSIENT) transientFailures++
427+
if (outcome == AckOutcome.PERMANENT) permanentFailures++
399428
if (abortPass) break
400429
}
401430

@@ -406,6 +435,43 @@ open class BillingManager @Inject constructor(
406435
}
407436
scheduleAckRetry()
408437
}
438+
439+
AckPassOutcome(transient = transientFailures, permanent = permanentFailures)
440+
}
441+
442+
/** Outcome of an explicit safety-net sweep, see [ensureAllAcknowledged]. */
443+
enum class AckSweepResult { COMPLETE, RETRY, PERMANENT_FAILURE }
444+
445+
/**
446+
* One self-contained acknowledgement sweep for the persistent safety net (PurchaseAckWorker):
447+
* refresh from Play, then acknowledge everything unacknowledged IN THIS COROUTINE. The reactive
448+
* ack collector consumes purchase state asynchronously, so a caller that needs proof the acks
449+
* actually happened before it reports success (a worker deciding success vs retry) cannot rely
450+
* on it. Never throws except cancellation.
451+
*/
452+
open suspend fun ensureAllAcknowledged(): AckSweepResult {
453+
log(TAG) { "ensureAllAcknowledged()" }
454+
val fresh = try {
455+
useConnection { refreshPurchases() }
456+
} catch (e: CancellationException) {
457+
throw e
458+
} catch (e: Exception) {
459+
log(TAG, WARN) { "ensureAllAcknowledged(): refresh failed: ${e.asLog()}" }
460+
return AckSweepResult.RETRY
461+
}
462+
// Same bookkeeping every other refresh exit owes: grace episode clock + dead-binder teardown.
463+
processReconciliation(fresh)
464+
// Pending payments are filtered here, exactly like the reactive collector does: acknowledging
465+
// one is a protocol error Play rejects permanently.
466+
val outcome = runAckPass(fresh.purchases.purchased())
467+
return when {
468+
// An incomplete refresh may be hiding an unacknowledged purchase of the failed type, and
469+
// a transient ack failure is retriable by definition.
470+
outcome.transient > 0 || !fresh.isComplete -> AckSweepResult.RETRY
471+
// Play will keep rejecting these no matter how often the worker comes back.
472+
outcome.permanent > 0 -> AckSweepResult.PERMANENT_FAILURE
473+
else -> AckSweepResult.COMPLETE
474+
}
409475
}
410476

411477
// A purchase Play will keep rejecting: report it once per token, then stay quiet. The pass still
@@ -581,6 +647,10 @@ open class BillingManager @Inject constructor(
581647
BillingResponseCode.ITEM_NOT_OWNED,
582648
)
583649

650+
// Play auto-refunds purchases not acknowledged within 3 days; every safety-net deadline
651+
// derives from this.
652+
const val ACK_SAFETY_NET_DEADLINE_MS = 3 * 24 * 60 * 60 * 1000L
653+
584654
private const val INITIAL_REFRESH_TIMEOUT_MS = 30_000L
585655
private const val MAX_BACKOFF_MS = 300_000L
586656

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package eu.darken.amply.upgrade.core.billing.work
2+
3+
import androidx.work.BackoffPolicy
4+
import androidx.work.Constraints
5+
import androidx.work.ExistingWorkPolicy
6+
import androidx.work.NetworkType
7+
import androidx.work.OneTimeWorkRequestBuilder
8+
import androidx.work.WorkManager
9+
import androidx.work.await
10+
import androidx.work.workDataOf
11+
import eu.darken.amply.BuildConfig
12+
import eu.darken.amply.common.debug.logging.Logging.Priority.WARN
13+
import eu.darken.amply.common.debug.logging.log
14+
import eu.darken.amply.common.debug.logging.logTag
15+
import eu.darken.amply.upgrade.core.billing.BillingManager
16+
import kotlinx.coroutines.withTimeoutOrNull
17+
import java.util.concurrent.TimeUnit
18+
import javax.inject.Inject
19+
import javax.inject.Provider
20+
import javax.inject.Singleton
21+
22+
/**
23+
* Arms the [PurchaseAckWorker] safety net. Two triggers:
24+
* - a billing flow is about to launch (armed and awaited BEFORE the Play sheet, so the WorkManager
25+
* DB transaction lands even if the process dies around the sheet),
26+
* - an ack pass discovered unacknowledged purchases (called directly, pre-attempt, from
27+
* [BillingManager]'s ack pass).
28+
*
29+
* `open` for the same reason [BillingManager] is: the billing tests substitute it to assert the
30+
* arming contract without standing up WorkManager.
31+
*/
32+
@Singleton
33+
open class PurchaseAckScheduler @Inject constructor(
34+
// Resolved on the first arm, not at construction: AmplyApp eagerly injects UpgradeSurfaceSync
35+
// (and with it the whole billing stack) during Application field injection, and resolving
36+
// WorkManager there triggers its on-demand initialization before the Application's worker
37+
// factory field is set.
38+
private val workManager: Provider<WorkManager>,
39+
) {
40+
41+
// A genuinely new flow refreshes the watch window: REPLACE the previous LAUNCH watch. The
42+
// worker sweeps ALL unacknowledged purchases, so replacing an older watch loses nothing — and a
43+
// pending rescue for an already-discovered purchase has its own identity, so starting another
44+
// purchase can never displace it. The long delay keeps the worker out of the window where the
45+
// user may still be in the Play sheet.
46+
open suspend fun armForBillingFlowLaunch() = arm(
47+
name = WORK_NAME_LAUNCH,
48+
policy = ExistingWorkPolicy.REPLACE,
49+
expiresAt = System.currentTimeMillis() + BillingManager.ACK_SAFETY_NET_DEADLINE_MS,
50+
initialDelayMs = LAUNCH_DELAY_MS,
51+
)
52+
53+
// Any pending rescue already covers every unacknowledged purchase: KEEP it. Once completed work
54+
// exists, KEEP inserts a fresh request. Short delay — the purchase already EXISTS (unlike the
55+
// launch trigger), possibly for days, so waiting 30min could waste real deadline time.
56+
// Accepted edge of KEEP, within the rescue lane only: a pending request keeps its original
57+
// (possibly earlier) expiry; a newer purchase with a later deadline is only re-covered once a
58+
// later pass re-arms after the old work completed. Bounded residual, only reachable via
59+
// out-of-band purchases.
60+
open suspend fun armForUnackedPurchases(expiresAt: Long) = arm(
61+
name = WORK_NAME_RESCUE,
62+
policy = ExistingWorkPolicy.KEEP,
63+
expiresAt = expiresAt,
64+
initialDelayMs = DISCOVERY_DELAY_MS,
65+
)
66+
67+
private suspend fun arm(
68+
name: String,
69+
policy: ExistingWorkPolicy,
70+
expiresAt: Long,
71+
initialDelayMs: Long,
72+
) {
73+
if (expiresAt <= System.currentTimeMillis()) {
74+
// Play has already voided (or is about to void) such a purchase; a sweep can't help.
75+
log(TAG, WARN) { "arm($policy): deadline $expiresAt already passed, not scheduling" }
76+
return
77+
}
78+
val request = OneTimeWorkRequestBuilder<PurchaseAckWorker>().apply {
79+
setConstraints(
80+
Constraints.Builder().apply {
81+
setRequiredNetworkType(NetworkType.CONNECTED)
82+
}.build(),
83+
)
84+
// Launch trigger: the worker must not run while the user may still be in the Play sheet
85+
// — an immediate sweep would find nothing unacknowledged, report success, and complete
86+
// the net before the purchase it exists for even happened.
87+
setInitialDelay(initialDelayMs, TimeUnit.MILLISECONDS)
88+
setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_MS, TimeUnit.MILLISECONDS)
89+
setInputData(workDataOf(PurchaseAckWorker.KEY_EXPIRES_AT to expiresAt))
90+
}.build()
91+
92+
// Await the enqueue: the caller arms this because the process may die at any moment — a
93+
// fire-and-forget enqueue could be lost with it. Cancellable and BOUNDED: every caller needs
94+
// a durable enqueue without an unbounded stall — a WorkManager that never settles must
95+
// become an exception (handled fail-open by every caller) instead of a hang (which on the
96+
// launch lane would park the purchase and its busy guard forever).
97+
val operation = workManager.get().enqueueUniqueWork(name, policy, request)
98+
withTimeoutOrNull(ENQUEUE_TIMEOUT_MS) { operation.await() }
99+
?: throw IllegalStateException("WorkManager enqueue did not settle within ${ENQUEUE_TIMEOUT_MS}ms")
100+
log(TAG) { "arm($policy): safety net armed, expiresAt=$expiresAt" }
101+
}
102+
103+
companion object {
104+
// WorkManager persists these names AND the worker's class name in its DB across app updates:
105+
// keep all of them stable while old work may exist (hence the version suffix for future
106+
// changes). Separate identities per trigger: the launch watch's REPLACE must not be able to
107+
// displace a pending rescue for a purchase that already exists.
108+
private val WORK_NAME_LAUNCH = "${BuildConfig.APPLICATION_ID}.gplay.purchase-ack.launch.v1"
109+
private val WORK_NAME_RESCUE = "${BuildConfig.APPLICATION_ID}.gplay.purchase-ack.rescue.v1"
110+
111+
private const val LAUNCH_DELAY_MS = 30 * 60 * 1000L
112+
private const val DISCOVERY_DELAY_MS = 60 * 1000L
113+
private const val BACKOFF_DELAY_MS = 30 * 60 * 1000L
114+
private const val ENQUEUE_TIMEOUT_MS = 10 * 1000L
115+
116+
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckScheduler")
117+
}
118+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package eu.darken.amply.upgrade.core.billing.work
2+
3+
import android.content.Context
4+
import androidx.hilt.work.HiltWorker
5+
import androidx.work.CoroutineWorker
6+
import androidx.work.ListenableWorker.Result
7+
import androidx.work.WorkerParameters
8+
import dagger.assisted.Assisted
9+
import dagger.assisted.AssistedInject
10+
import eu.darken.amply.common.debug.logging.Logging.Priority.INFO
11+
import eu.darken.amply.common.debug.logging.Logging.Priority.WARN
12+
import eu.darken.amply.common.debug.logging.log
13+
import eu.darken.amply.common.debug.logging.logTag
14+
import eu.darken.amply.upgrade.core.billing.BillingManager
15+
import kotlinx.coroutines.withTimeoutOrNull
16+
17+
/**
18+
* Persistent acknowledgement safety net, armed by [PurchaseAckScheduler].
19+
*
20+
* Play auto-refunds (and revokes) any purchase not acknowledged within 3 days. The in-process ack
21+
* machinery in [BillingManager] handles every case where the process lives long enough — this
22+
* worker covers the case it can't: the process dies around the Play purchase sheet (OEM task
23+
* killers) and the user doesn't reopen the app before the deadline. Play voids such purchases and
24+
* revokes the entitlement, so the user loses what they paid for.
25+
*
26+
* Self-completing by design: nothing cancels this work from the foreground ack path (an ack pass can
27+
* legitimately see zero unacknowledged purchases while the Play sheet is still open, which must not
28+
* tear down the net). The redundant sweep after a successful foreground ack is one purchase query.
29+
*/
30+
@HiltWorker
31+
class PurchaseAckWorker @AssistedInject constructor(
32+
@Assisted private val context: Context,
33+
@Assisted private val params: WorkerParameters,
34+
private val billingManager: BillingManager,
35+
) : CoroutineWorker(context, params) {
36+
37+
override suspend fun doWork(): Result {
38+
val expiresAt = inputData.getLong(KEY_EXPIRES_AT, 0L)
39+
log(TAG) { "doWork(): attempt=$runAttemptCount, expiresAt=$expiresAt" }
40+
41+
if (!isWorthSweeping(System.currentTimeMillis(), expiresAt)) {
42+
// Past Play's refund deadline (or malformed input): retrying can't achieve anything.
43+
// failure() is deliberate over success() — it is visible in WorkManager diagnostics, and
44+
// a completed state lets a later KEEP enqueue insert fresh work.
45+
log(TAG, WARN) { "doWork(): deadline passed, giving up" }
46+
return Result.failure()
47+
}
48+
49+
// Bounded well below WorkManager's 10-minute execution limit, but generous enough for the
50+
// connection wait plus the per-purchase inline retries. A sweep that ran out of time is a
51+
// transient outcome, not a verdict. External cancellation propagates out of doWork — it must
52+
// never be converted into success.
53+
val sweep = withTimeoutOrNull(SWEEP_TIMEOUT_MS) {
54+
billingManager.ensureAllAcknowledged()
55+
}
56+
log(TAG, INFO) { "doWork(): sweep=$sweep" }
57+
58+
return mapSweep(sweep, System.currentTimeMillis(), expiresAt)
59+
}
60+
61+
companion object {
62+
// Persisted in WorkManager's request data — keep the key stable while old work may exist.
63+
const val KEY_EXPIRES_AT = "purchase.ack.expiresAt"
64+
65+
private const val SWEEP_TIMEOUT_MS = 4 * 60 * 1000L
66+
67+
// Pure so the retry/expiry decision is unit-testable without a WorkManager test harness.
68+
internal fun isWorthSweeping(now: Long, expiresAt: Long): Boolean =
69+
expiresAt > 0L && now < expiresAt
70+
71+
internal fun mapSweep(
72+
sweep: BillingManager.AckSweepResult?,
73+
now: Long,
74+
expiresAt: Long,
75+
): Result = when (sweep) {
76+
BillingManager.AckSweepResult.COMPLETE -> Result.success()
77+
BillingManager.AckSweepResult.PERMANENT_FAILURE -> Result.failure()
78+
// RETRY or timeout (null): keep trying until the deadline. WorkManager's exponential
79+
// backoff caps at 5h, so the 3-day window still yields many attempts.
80+
else -> if (now < expiresAt) Result.retry() else Result.failure()
81+
}
82+
83+
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckWorker")
84+
}
85+
}

0 commit comments

Comments
 (0)