mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(upgrade): 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 launchBillingFlow 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 so eager Application-time construction of the billing stack cannot trigger WorkManager's on-demand initialization prematurely. - 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. This is a port of d4rken-org/sdmaid-se#2685; the ported sources are byte-identical to the donor apart from the package rename. CAPod had no explicit WorkManager wiring at all (work-runtime only arrived transitively through Glance), so this also adds it: - addWorkerManager() pinning androidx.work 2.7.1, the version already resolved via Glance, plus androidx.hilt:hilt-work and its KSP compiler. work-runtime-ktx is required at 2.7.1: CoroutineWorker, Operation.await, OneTimeWorkRequestBuilder and workDataOf all still live in the ktx artifact at that version. androidx.hilt moves 1.0.0 -> 1.2.0 (by conflict resolution) because 1.0.0's hilt-compiler ships no KSP SymbolProcessorProvider, so @HiltWorker would generate nothing. - WorkManagerModule providing the singleton WorkManager. - App implements Configuration.Provider with the injected HiltWorkerFactory. WorkManager 2.7.1 still declares that interface as getWorkManagerConfiguration(), not the later property form. - The manifest removes androidx.work's startup initializer so the on-demand configuration is the one that takes effect. FOSS stays untouched behaviour-wise: all new billing types live in src/gplay, workers need no manifest entry, and the worker factory resolves the worker only in gplay variants.
This commit is contained in:
@@ -189,6 +189,7 @@ dependencies {
|
||||
|
||||
addCompose()
|
||||
addGlance()
|
||||
addWorkerManager()
|
||||
addDataStore()
|
||||
addNavigation3()
|
||||
addSerialization()
|
||||
|
||||
@@ -23,6 +23,7 @@ import eu.darken.capod.common.upgrade.core.billing.Sku
|
||||
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
|
||||
import eu.darken.capod.common.upgrade.core.billing.UserCanceledBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.redacted
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
@@ -58,6 +59,7 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
private val billingManager: BillingManager,
|
||||
private val billingCache: BillingCache,
|
||||
private val curriculumVitae: CurriculumVitae,
|
||||
private val ackScheduler: PurchaseAckScheduler,
|
||||
) : UpgradeRepo {
|
||||
|
||||
override val storeSite: String = STORE_SITE
|
||||
@@ -137,6 +139,7 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
.onEach { failedAt -> recordProUnconfirmed(failedAt) }
|
||||
.setupCommonEventHandlers(TAG) { "connectionFailureRecorder" }
|
||||
.launchIn(scope)
|
||||
|
||||
}
|
||||
|
||||
// Settledness travels WITH the ownership data (Info.isSettled), never on a parallel flow —
|
||||
@@ -255,6 +258,18 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Persistent ack safety net, launch trigger: armed and AWAITED before the Play sheet
|
||||
// can open, so the WorkManager DB transaction lands even if the process dies around
|
||||
// the sheet — the exact window behind Play's unacknowledged-purchase auto-refunds.
|
||||
// Failure to arm never blocks the purchase; the foreground ack path still exists.
|
||||
try {
|
||||
ackScheduler.armForBillingFlowLaunch()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to arm ack safety net for launch: ${e.asLog()}" }
|
||||
}
|
||||
|
||||
// Bounded, like every other Play path (refresh, restore, SKU query, ack). useConnection
|
||||
// waits for a healthy connection indefinitely, so a Play outage between rendering the
|
||||
// offers and this tap would park the launch forever — with launchBusySku still held,
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.upgrade.core.billing.client.BillingConnection
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnectionProvider
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.isPurchased
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.redacted
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -25,6 +26,8 @@ import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -33,6 +36,7 @@ import javax.inject.Singleton
|
||||
class BillingManager @Inject constructor(
|
||||
@AppScope private val scope: CoroutineScope,
|
||||
connectionProvider: BillingConnectionProvider,
|
||||
private val ackScheduler: PurchaseAckScheduler,
|
||||
) {
|
||||
|
||||
// Fresh Play data plus its provenance: a query result covers owned products of the queried
|
||||
@@ -174,6 +178,11 @@ class BillingManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Serializes acknowledgement work between the reactive ack collector and explicit
|
||||
// ensureAllAcknowledged() sweeps (PurchaseAckWorker): both paths mutate the token bookkeeping
|
||||
// sets and both must never double-drive the same purchase's inline retry sequence.
|
||||
private val ackMutex = Mutex()
|
||||
|
||||
// Re-drives the ack pass WITHOUT a new purchases emission: `purchases` is distinctUntilChanged,
|
||||
// so a refresh returning a byte-identical (still unacknowledged) list is deduped and could never
|
||||
// retry a failed ack -- the pipeline starved until Play sent something different. Declared ahead
|
||||
@@ -256,13 +265,13 @@ class BillingManager @Inject constructor(
|
||||
// below. The immutable Purchase snapshot keeps reporting isAcknowledged=false until a fresh Play
|
||||
// query supersedes it, so the ack re-fires every emission until then; re-acking is a documented
|
||||
// no-op on Play's side, whereas skipping a needed ack gets the purchase auto-refunded after 3
|
||||
// days -- so the ack stays unconditional and this set only quiets the log spam. Single
|
||||
// sequential collector (the ack pass below), no locking needed.
|
||||
// days -- so the ack stays unconditional and this set only quiets the log spam. Confined by
|
||||
// ackMutex (the collector's pass and explicit sweeps both run under it).
|
||||
private val loggedAckTokens = mutableSetOf<String>()
|
||||
|
||||
// Tokens whose PERMANENT ack failure was already reported. Play will keep rejecting these
|
||||
// (developer error, item not owned, unsupported feature), so the bug report fires once per token
|
||||
// instead of once per pass. Same single-collector confinement as loggedAckTokens.
|
||||
// instead of once per pass. Same ackMutex confinement as loggedAckTokens.
|
||||
private val reportedAckFailures = mutableSetOf<String>()
|
||||
|
||||
// At most one reschedule timer in flight: repeated failures must not stack timers.
|
||||
@@ -296,10 +305,13 @@ class BillingManager @Inject constructor(
|
||||
// .isAcknowledged, whose immutable snapshot stays false until a fresh Play query.
|
||||
private enum class AckOutcome { SUCCESS, TRANSIENT, PERMANENT }
|
||||
|
||||
// Aggregate outcome of one ack pass; ensureAllAcknowledged() maps it to a sweep result.
|
||||
data class AckPassOutcome(val transient: Int, val permanent: Int)
|
||||
|
||||
// One acknowledgement pass over the canonical purchase list. Never throws except cancellation:
|
||||
// transient failures schedule a re-drive, permanent ones are reported and left to organic fresh
|
||||
// -data signals.
|
||||
private suspend fun runAckPass(purchases: Collection<Purchase>) {
|
||||
private suspend fun runAckPass(purchases: Collection<Purchase>): AckPassOutcome = ackMutex.withLock {
|
||||
val needAck = purchases.filter {
|
||||
// The canonical list carries pending payments too. Play rejects acknowledging one
|
||||
// PERMANENTLY, so an unfiltered pass would fire a bug report for every pending purchase,
|
||||
@@ -317,7 +329,23 @@ class BillingManager @Inject constructor(
|
||||
needsAck
|
||||
}
|
||||
|
||||
if (needAck.isNotEmpty()) {
|
||||
// Arm the persistent safety net BEFORE attempting anything, and AWAIT the enqueue (the
|
||||
// scheduler bounds it): the inline retries below can span minutes, and a process death
|
||||
// inside them must not strand the purchase until Play's 3-day auto-refund. A deferred
|
||||
// signal (channel + collector) would reintroduce exactly that window. Fail-open: the
|
||||
// net is an extra layer, never a reason to skip the acks themselves.
|
||||
try {
|
||||
ackScheduler.armForUnackedPurchases(needAck.maxOf { it.purchaseTime } + ACK_SAFETY_NET_DEADLINE_MS)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to arm ack safety net: ${e.asLog()}" }
|
||||
}
|
||||
}
|
||||
|
||||
var transientFailures = 0
|
||||
var permanentFailures = 0
|
||||
|
||||
for (purchase in needAck) {
|
||||
// First ack of a token is INFO; idempotent repeats drop to DEBUG. This never gates the
|
||||
@@ -381,6 +409,7 @@ class BillingManager @Inject constructor(
|
||||
}
|
||||
|
||||
if (outcome == AckOutcome.TRANSIENT) transientFailures++
|
||||
if (outcome == AckOutcome.PERMANENT) permanentFailures++
|
||||
if (abortPass) break
|
||||
}
|
||||
|
||||
@@ -391,6 +420,41 @@ class BillingManager @Inject constructor(
|
||||
}
|
||||
scheduleAckRetry()
|
||||
}
|
||||
|
||||
AckPassOutcome(transient = transientFailures, permanent = permanentFailures)
|
||||
}
|
||||
|
||||
// Outcome of an explicit safety-net sweep, see ensureAllAcknowledged().
|
||||
enum class AckSweepResult { COMPLETE, RETRY, PERMANENT_FAILURE }
|
||||
|
||||
/**
|
||||
* One self-contained acknowledgement sweep for the persistent safety net (PurchaseAckWorker):
|
||||
* refresh from Play, then acknowledge everything unacknowledged IN THIS COROUTINE. The reactive
|
||||
* ack collector consumes purchase state asynchronously, so a caller that needs proof the acks
|
||||
* actually happened before it reports success (a worker deciding success vs retry) cannot rely
|
||||
* on it. Never throws except cancellation.
|
||||
*/
|
||||
suspend fun ensureAllAcknowledged(): AckSweepResult {
|
||||
log(TAG) { "ensureAllAcknowledged()" }
|
||||
val fresh = try {
|
||||
useConnection { refreshPurchases() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "ensureAllAcknowledged(): refresh failed: ${e.asLog()}" }
|
||||
return AckSweepResult.RETRY
|
||||
}
|
||||
// Same bookkeeping every other refresh exit owes: grace episode clock + dead-binder teardown.
|
||||
processReconciliation(fresh)
|
||||
val outcome = runAckPass(fresh.purchases)
|
||||
return when {
|
||||
// An incomplete refresh may be hiding an unacknowledged purchase of the failed type,
|
||||
// and a transient ack failure is retriable by definition.
|
||||
outcome.transient > 0 || !fresh.isComplete -> AckSweepResult.RETRY
|
||||
// Play will keep rejecting these no matter how often the worker comes back.
|
||||
outcome.permanent > 0 -> AckSweepResult.PERMANENT_FAILURE
|
||||
else -> AckSweepResult.COMPLETE
|
||||
}
|
||||
}
|
||||
|
||||
// A purchase Play will keep rejecting: report it once per token, then stay quiet. The pass still
|
||||
@@ -586,6 +650,10 @@ class BillingManager @Inject constructor(
|
||||
BillingResponseCode.ITEM_NOT_OWNED,
|
||||
)
|
||||
|
||||
// Play auto-refunds purchases not acknowledged within 3 days; every safety-net deadline
|
||||
// derives from this.
|
||||
const val ACK_SAFETY_NET_DEADLINE_MS = 3 * 24 * 60 * 60 * 1000L
|
||||
|
||||
private const val INITIAL_REFRESH_TIMEOUT_MS = 30_000L
|
||||
private const val MAX_BACKOFF_MS = 300_000L
|
||||
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.await
|
||||
import androidx.work.workDataOf
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.core.billing.BillingManager
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Arms the [PurchaseAckWorker] safety net. Two triggers:
|
||||
* - a billing flow is about to launch (armed and awaited BEFORE the Play sheet, so the WorkManager
|
||||
* DB transaction lands even if the process dies around the sheet),
|
||||
* - an ack pass discovered unacknowledged purchases (called directly, pre-attempt, from
|
||||
* BillingManager's runAckPass).
|
||||
*/
|
||||
@Singleton
|
||||
class PurchaseAckScheduler @Inject constructor(
|
||||
// Resolved on the first arm, not at construction: fleet App classes eagerly inject the billing
|
||||
// stack during Application field injection, and resolving WorkManager there can trigger its
|
||||
// on-demand initialization before the Application's worker factory field is set.
|
||||
private val workManager: Provider<WorkManager>,
|
||||
) {
|
||||
|
||||
// A genuinely new flow refreshes the watch window: REPLACE the previous LAUNCH watch. The
|
||||
// worker sweeps ALL unacknowledged purchases, so replacing an older watch loses nothing — and a
|
||||
// pending rescue for an already-discovered purchase has its own identity, so starting another
|
||||
// purchase can never displace it. The long delay keeps the worker out of the window where the
|
||||
// user may still be in the Play sheet.
|
||||
suspend fun armForBillingFlowLaunch() = arm(
|
||||
name = WORK_NAME_LAUNCH,
|
||||
policy = ExistingWorkPolicy.REPLACE,
|
||||
expiresAt = System.currentTimeMillis() + BillingManager.ACK_SAFETY_NET_DEADLINE_MS,
|
||||
initialDelayMs = LAUNCH_DELAY_MS,
|
||||
)
|
||||
|
||||
// Any pending rescue already covers every unacknowledged purchase: KEEP it. Once completed
|
||||
// work exists, KEEP inserts a fresh request. Short delay — the purchase already EXISTS (unlike
|
||||
// the launch trigger), possibly for days, so waiting 30min could waste real deadline time.
|
||||
// Accepted edge of KEEP, within the rescue lane only: a pending request keeps its original
|
||||
// (possibly earlier) expiry; a newer purchase with a later deadline is only re-covered once a
|
||||
// later pass re-arms after the old work completed. Bounded residual, only reachable via
|
||||
// out-of-band purchases.
|
||||
suspend fun armForUnackedPurchases(expiresAt: Long) = arm(
|
||||
name = WORK_NAME_RESCUE,
|
||||
policy = ExistingWorkPolicy.KEEP,
|
||||
expiresAt = expiresAt,
|
||||
initialDelayMs = DISCOVERY_DELAY_MS,
|
||||
)
|
||||
|
||||
private suspend fun arm(
|
||||
name: String,
|
||||
policy: ExistingWorkPolicy,
|
||||
expiresAt: Long,
|
||||
initialDelayMs: Long,
|
||||
) {
|
||||
if (expiresAt <= System.currentTimeMillis()) {
|
||||
// Play has already voided (or is about to void) such a purchase; a sweep can't help.
|
||||
log(TAG, WARN) { "arm($policy): deadline $expiresAt already passed, not scheduling" }
|
||||
return
|
||||
}
|
||||
val request = OneTimeWorkRequestBuilder<PurchaseAckWorker>().apply {
|
||||
setConstraints(
|
||||
Constraints.Builder().apply {
|
||||
setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
}.build()
|
||||
)
|
||||
// Launch trigger: the worker must not run while the user may still be in the Play
|
||||
// sheet — an immediate sweep would find nothing unacknowledged, report success, and
|
||||
// complete the net before the purchase it exists for even happened.
|
||||
setInitialDelay(initialDelayMs, TimeUnit.MILLISECONDS)
|
||||
setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_MS, TimeUnit.MILLISECONDS)
|
||||
setInputData(workDataOf(PurchaseAckWorker.KEY_EXPIRES_AT to expiresAt))
|
||||
}.build()
|
||||
|
||||
// Await the enqueue: the caller arms this because the process may die at any moment — a
|
||||
// fire-and-forget enqueue could be lost with it. Cancellable and BOUNDED: every caller
|
||||
// needs a durable enqueue without an unbounded stall — a WorkManager that never settles
|
||||
// must become an exception (handled fail-open by every caller) instead of a hang (which on
|
||||
// the launch lane would park the purchase and its busy guard forever).
|
||||
val operation = workManager.get().enqueueUniqueWork(name, policy, request)
|
||||
withTimeoutOrNull(ENQUEUE_TIMEOUT_MS) { operation.await() }
|
||||
?: throw IllegalStateException("WorkManager enqueue did not settle within ${ENQUEUE_TIMEOUT_MS}ms")
|
||||
log(TAG) { "arm($policy): safety net armed, expiresAt=$expiresAt" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
// WorkManager persists these names AND the worker's class name in its DB across app
|
||||
// updates: keep all of them stable while old work may exist (hence the version suffix for
|
||||
// future changes). Separate identities per trigger: the launch watch's REPLACE must not be
|
||||
// able to displace a pending rescue for a purchase that already exists.
|
||||
private val WORK_NAME_LAUNCH = "${BuildConfigWrap.APPLICATION_ID}.gplay.purchase-ack.launch.v1"
|
||||
private val WORK_NAME_RESCUE = "${BuildConfigWrap.APPLICATION_ID}.gplay.purchase-ack.rescue.v1"
|
||||
|
||||
private const val LAUNCH_DELAY_MS = 30 * 60 * 1000L
|
||||
private const val DISCOVERY_DELAY_MS = 60 * 1000L
|
||||
private const val BACKOFF_DELAY_MS = 30 * 60 * 1000L
|
||||
private const val ENQUEUE_TIMEOUT_MS = 10 * 1000L
|
||||
|
||||
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckScheduler")
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ListenableWorker.Result
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.core.billing.BillingManager
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Persistent acknowledgement safety net, armed by [PurchaseAckScheduler].
|
||||
*
|
||||
* Play auto-refunds (and revokes) any purchase not acknowledged within 3 days. The in-process ack
|
||||
* machinery in [BillingManager] handles every case where the process lives long enough — this
|
||||
* worker covers the case it can't: the process dies around the Play purchase sheet (OEM task
|
||||
* killers) and the user doesn't reopen the app before the deadline. Play voids such purchases and
|
||||
* revokes the entitlement, so the user loses what they signed up for.
|
||||
*
|
||||
* Self-completing by design: nothing cancels this work from the foreground ack path (an ack pass
|
||||
* can legitimately see zero unacknowledged purchases while the Play sheet is still open, which
|
||||
* must not tear down the net). The redundant sweep after a successful foreground ack is one
|
||||
* purchase query.
|
||||
*/
|
||||
@HiltWorker
|
||||
class PurchaseAckWorker @AssistedInject constructor(
|
||||
@Assisted private val context: Context,
|
||||
@Assisted private val params: WorkerParameters,
|
||||
private val billingManager: BillingManager,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val expiresAt = inputData.getLong(KEY_EXPIRES_AT, 0L)
|
||||
log(TAG) { "doWork(): attempt=$runAttemptCount, expiresAt=$expiresAt" }
|
||||
|
||||
if (!isWorthSweeping(System.currentTimeMillis(), expiresAt)) {
|
||||
// Past Play's refund deadline (or malformed input): retrying can't achieve anything.
|
||||
// failure() is deliberate over success() — it is visible in WorkManager diagnostics,
|
||||
// and a completed state lets a later KEEP enqueue insert fresh work.
|
||||
log(TAG, WARN) { "doWork(): deadline passed, giving up" }
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// Bounded well below WorkManager's 10-minute execution limit, but generous enough for the
|
||||
// connection wait plus the per-purchase inline retries. A sweep that ran out of time is a
|
||||
// transient outcome, not a verdict. External cancellation propagates out of doWork — it
|
||||
// must never be converted into success.
|
||||
val sweep = withTimeoutOrNull(SWEEP_TIMEOUT_MS) {
|
||||
billingManager.ensureAllAcknowledged()
|
||||
}
|
||||
log(TAG, INFO) { "doWork(): sweep=$sweep" }
|
||||
|
||||
return mapSweep(sweep, System.currentTimeMillis(), expiresAt)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Persisted in WorkManager's request data — keep the key stable while old work may exist.
|
||||
const val KEY_EXPIRES_AT = "purchase.ack.expiresAt"
|
||||
|
||||
private const val SWEEP_TIMEOUT_MS = 4 * 60 * 1000L
|
||||
|
||||
// Pure so the retry/expiry decision is unit-testable without a WorkManager test harness.
|
||||
internal fun isWorthSweeping(now: Long, expiresAt: Long): Boolean =
|
||||
expiresAt > 0L && now < expiresAt
|
||||
|
||||
internal fun mapSweep(
|
||||
sweep: BillingManager.AckSweepResult?,
|
||||
now: Long,
|
||||
expiresAt: Long,
|
||||
): Result = when (sweep) {
|
||||
BillingManager.AckSweepResult.COMPLETE -> Result.success()
|
||||
BillingManager.AckSweepResult.PERMANENT_FAILURE -> Result.failure()
|
||||
// RETRY or timeout (null): keep trying until the deadline. WorkManager's exponential
|
||||
// backoff caps at 5h, so the 3-day window still yields many attempts.
|
||||
else -> if (now < expiresAt) Result.retry() else Result.failure()
|
||||
}
|
||||
|
||||
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckWorker")
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,19 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
|
||||
</provider>
|
||||
|
||||
<!-- Debug stuff-->
|
||||
<activity
|
||||
android:name=".common.debug.recording.ui.RecorderActivity"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||
import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||
@@ -34,8 +37,9 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
open class App : Application() {
|
||||
open class App : Application(), Configuration.Provider {
|
||||
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
@Inject lateinit var autoReporting: AutomaticBugReporter
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var widgetManager: WidgetManager
|
||||
@@ -96,6 +100,22 @@ open class App : Application() {
|
||||
.launchIn(appScope)
|
||||
}
|
||||
|
||||
// WorkManager 2.7.1 (see Dependencies.addWorkerManager) still declares Configuration.Provider
|
||||
// as getWorkManagerConfiguration(); the `workManagerConfiguration` property form only exists
|
||||
// from 2.9.0 onwards.
|
||||
override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(
|
||||
when {
|
||||
BuildConfigWrap.DEBUG -> android.util.Log.VERBOSE
|
||||
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.DEV -> android.util.Log.DEBUG
|
||||
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.BETA -> android.util.Log.INFO
|
||||
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.RELEASE -> android.util.Log.WARN
|
||||
else -> android.util.Log.VERBOSE
|
||||
}
|
||||
)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
internal val TAG = logTag("CAP")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.darken.capod.common.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
class WorkManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(context: Context): WorkManager = WorkManager.getInstance(context)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import eu.darken.capod.common.upgrade.core.billing.ItemAlreadyOwnedBillingExcept
|
||||
import eu.darken.capod.common.upgrade.core.billing.PendingPurchaseBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.PurchasedSku
|
||||
import eu.darken.capod.common.upgrade.core.billing.UserCanceledBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import eu.darken.capod.main.core.CurriculumVitae
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -55,6 +56,7 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
private val billingManager = mockk<BillingManager>()
|
||||
private val billingCache = mockk<BillingCache>()
|
||||
private val curriculumVitae = mockk<CurriculumVitae>(relaxed = true)
|
||||
private val ackScheduler = mockk<PurchaseAckScheduler>(relaxed = true)
|
||||
private lateinit var lastProAtMock: DataStoreValue<Long>
|
||||
private lateinit var lastProSkuMock: DataStoreValue<String>
|
||||
private lateinit var proUnconfirmedMock: DataStoreValue<Long>
|
||||
@@ -102,7 +104,7 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
}
|
||||
every { billingCache.proUnconfirmedSince } returns proUnconfirmedMock
|
||||
coJustRun { billingCache.stampLastProState(any(), any()) }
|
||||
return UpgradeRepoGplay(scope, billingManager, billingCache, curriculumVitae)
|
||||
return UpgradeRepoGplay(scope, billingManager, billingCache, curriculumVitae, ackScheduler)
|
||||
}
|
||||
|
||||
private fun result(code: Int): BillingResult = BillingResult.newBuilder().setResponseCode(code).build()
|
||||
@@ -1024,5 +1026,34 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
repo.autoRestoreBusy.first() shouldBe false
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ack safety net
|
||||
|
||||
@Test fun `launching a billing flow arms the persistent ack safety net first`() = runTest2 {
|
||||
val order = mutableListOf<String>()
|
||||
coEvery { ackScheduler.armForBillingFlowLaunch() } coAnswers { order.add("arm") }
|
||||
coEvery { billingManager.startIapFlow(any(), any(), null) } coAnswers { order.add("launch") }
|
||||
|
||||
repo(lastProAt = 0L).startLaunch()
|
||||
|
||||
// Armed (and awaited) BEFORE the Play sheet can open: the process may die around the sheet,
|
||||
// and the WorkManager transaction has to land first to be worth anything.
|
||||
order shouldBe listOf("arm", "launch")
|
||||
}
|
||||
|
||||
@Test fun `a failing safety net arm never blocks the purchase flow`() = runTest2 {
|
||||
coEvery { ackScheduler.armForBillingFlowLaunch() } throws RuntimeException("workmanager broken")
|
||||
coJustRun { billingManager.startIapFlow(any(), any(), null) }
|
||||
|
||||
val errors = mutableListOf<Throwable>()
|
||||
repo(lastProAt = 0L).startLaunch { errors.add(it) }
|
||||
|
||||
// The net is best-effort: the foreground ack path still exists, the purchase must proceed.
|
||||
errors shouldBe emptyList()
|
||||
coVerify { billingManager.startIapFlow(any(), any(), null) }
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
+111
-3
@@ -13,8 +13,10 @@ import eu.darken.capod.common.upgrade.core.OurSku
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingClientException
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnection
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnectionProvider
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.collections.shouldNotContain
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThan
|
||||
import io.kotest.matchers.longs.shouldBeLessThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
@@ -52,6 +54,10 @@ import testhelpers.coroutine.runTest2
|
||||
|
||||
class BillingManagerTest : BaseTest() {
|
||||
|
||||
// Relaxed: the safety net is fail-open plumbing around the ack pass — only the dedicated
|
||||
// tests below assert on it.
|
||||
private val ackScheduler = mockk<PurchaseAckScheduler>(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkObject(Bugs)
|
||||
@@ -158,10 +164,10 @@ class BillingManagerTest : BaseTest() {
|
||||
}
|
||||
|
||||
private fun TestScope.manager(connection: BillingConnection): BillingManager =
|
||||
BillingManager(backgroundScope, providerOf(connection))
|
||||
BillingManager(backgroundScope, providerOf(connection), ackScheduler)
|
||||
|
||||
private fun TestScope.manager(provider: BillingConnectionProvider): BillingManager =
|
||||
BillingManager(backgroundScope, provider)
|
||||
BillingManager(backgroundScope, provider, ackScheduler)
|
||||
|
||||
// region launch failure mapping
|
||||
|
||||
@@ -1249,7 +1255,7 @@ class BillingManagerTest : BaseTest() {
|
||||
val conn = connection(purchasesFlow = purchases)
|
||||
val acks = conn.scriptAck { _, _ -> awaitCancellation() }
|
||||
val ackScope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
BillingManager(ackScope, providerOf(conn))
|
||||
BillingManager(ackScope, providerOf(conn), ackScheduler)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(unacked))
|
||||
@@ -1360,4 +1366,106 @@ class BillingManagerTest : BaseTest() {
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ack safety net sweep (ensureAllAcknowledged)
|
||||
|
||||
@Test fun `sweep acknowledges what its refresh returned and reports COMPLETE`() = runTest2 {
|
||||
val unacked = unackedPurchase("token-sweep")
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(unacked))))
|
||||
val acks = conn.scriptAck { _, _ -> result(BillingResponseCode.OK) }
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
// The ack happens IN this call, not via the async collector: the worker needs the
|
||||
// happens-before to report success.
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.COMPLETE
|
||||
acks.map { it.purchaseToken } shouldBe listOf("token-sweep")
|
||||
}
|
||||
|
||||
@Test fun `sweep with nothing to acknowledge reports COMPLETE`() = runTest2 {
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(purchase()))))
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.COMPLETE
|
||||
}
|
||||
|
||||
@Test fun `sweep reports RETRY when acks keep failing transiently`() = runTest2 {
|
||||
val unacked = unackedPurchase("token-sweep")
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(unacked))))
|
||||
conn.scriptAck { _, _ -> transientAckFailure() }
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.RETRY
|
||||
}
|
||||
|
||||
@Test fun `sweep reports PERMANENT_FAILURE on a permanently rejected ack`() = runTest2 {
|
||||
val unacked = unackedPurchase("token-sweep")
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(unacked))))
|
||||
conn.scriptAck { _, _ -> throw BillingClientException(result(BillingResponseCode.DEVELOPER_ERROR)) }
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.PERMANENT_FAILURE
|
||||
}
|
||||
|
||||
@Test fun `sweep reports RETRY on an incomplete refresh even with nothing to ack`() = runTest2 {
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), partialRefresh()))
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
// A failed product-type query may be hiding an unacknowledged purchase of that type: the
|
||||
// worker must come back instead of reporting the net complete.
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.RETRY
|
||||
}
|
||||
|
||||
@Test fun `sweep reports RETRY when the refresh itself fails`() = runTest2 {
|
||||
val conn = connection(refreshes = listOf(completeRefresh()))
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
coEvery { conn.refreshPurchases() } throws BillingException("Play down")
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.RETRY
|
||||
}
|
||||
|
||||
@Test fun `an ack pass arms the safety net before attempting, with the newest refund deadline`() = runTest2 {
|
||||
val order = mutableListOf<String>()
|
||||
coEvery { ackScheduler.armForUnackedPurchases(any()) } coAnswers { order.add("arm:${firstArg<Long>()}") }
|
||||
val purchases = purchasesFlow()
|
||||
val conn = connection(purchasesFlow = purchases)
|
||||
conn.scriptAck { _, _ ->
|
||||
order.add("ack")
|
||||
transientAckFailure()
|
||||
}
|
||||
manager(conn)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(unackedPurchase("token-1", time = 5_000L), unackedPurchase("token-2", time = 9_000L)))
|
||||
// runCurrent, NOT advanceUntilIdle: the scripted ack keeps failing, so idle-advancing would
|
||||
// spin through the 5-minute re-drive cycles forever. The first attempt runs undelayed.
|
||||
runCurrent()
|
||||
|
||||
// Armed (and awaited) BEFORE the first attempt: a process death during the inline retries
|
||||
// must still leave the persistent net in place. Deadline derives from the NEWEST purchase.
|
||||
order.first() shouldBe "arm:${9_000L + BillingManager.ACK_SAFETY_NET_DEADLINE_MS}"
|
||||
order.count { it == "ack" } shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test fun `a failing safety net arm never blocks the ack pass`() = runTest2 {
|
||||
coEvery { ackScheduler.armForUnackedPurchases(any()) } throws RuntimeException("workmanager broken")
|
||||
val purchases = purchasesFlow()
|
||||
val conn = connection(purchasesFlow = purchases)
|
||||
val acks = conn.scriptAck { _, _ -> result(BillingResponseCode.OK) }
|
||||
manager(conn)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(unackedPurchase("token-1")))
|
||||
runCurrent()
|
||||
|
||||
// The net is an extra layer: WorkManager being broken must never stop the ack itself.
|
||||
acks.map { it.purchaseToken } shouldBe listOf("token-1")
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.Operation
|
||||
import androidx.work.WorkManager
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldEndWith
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.coroutine.runTest2
|
||||
import javax.inject.Provider
|
||||
|
||||
class PurchaseAckSchedulerTest : BaseTest() {
|
||||
|
||||
// The enqueue is awaited: hand back an already-settled future so await() takes its fast path.
|
||||
private val enqueueFuture = mockk<ListenableFuture<Operation.State.SUCCESS>>().apply {
|
||||
every { isDone } returns true
|
||||
every { get() } returns mockk()
|
||||
}
|
||||
private val operation = mockk<Operation>().apply {
|
||||
every { result } returns enqueueFuture
|
||||
}
|
||||
private val workManager = mockk<WorkManager>().apply {
|
||||
every {
|
||||
enqueueUniqueWork(any<String>(), any<ExistingWorkPolicy>(), any<OneTimeWorkRequest>())
|
||||
} returns operation
|
||||
}
|
||||
|
||||
private fun create() = PurchaseAckScheduler(
|
||||
workManager = Provider { workManager },
|
||||
)
|
||||
|
||||
@Test fun `a billing flow launch arms the launch watch`() = runTest2 {
|
||||
create().armForBillingFlowLaunch()
|
||||
|
||||
val name = slot<String>()
|
||||
val policy = slot<ExistingWorkPolicy>()
|
||||
verify(exactly = 1) {
|
||||
workManager.enqueueUniqueWork(capture(name), capture(policy), any<OneTimeWorkRequest>())
|
||||
}
|
||||
name.captured shouldEndWith ".gplay.purchase-ack.launch.v1"
|
||||
policy.captured shouldBe ExistingWorkPolicy.REPLACE
|
||||
}
|
||||
|
||||
@Test fun `discovered unacknowledged purchases arm the rescue lane`() = runTest2 {
|
||||
create().armForUnackedPurchases(expiresAt = System.currentTimeMillis() + 60 * 1000L)
|
||||
|
||||
val name = slot<String>()
|
||||
val policy = slot<ExistingWorkPolicy>()
|
||||
verify(exactly = 1) {
|
||||
workManager.enqueueUniqueWork(capture(name), capture(policy), any<OneTimeWorkRequest>())
|
||||
}
|
||||
// A separate identity from the launch watch: a new purchase flow must not displace a
|
||||
// pending rescue for a purchase that already exists.
|
||||
name.captured shouldEndWith ".gplay.purchase-ack.rescue.v1"
|
||||
policy.captured shouldBe ExistingWorkPolicy.KEEP
|
||||
}
|
||||
|
||||
@Test fun `a passed deadline schedules nothing`() = runTest2 {
|
||||
create().armForUnackedPurchases(expiresAt = System.currentTimeMillis() - 60 * 1000L)
|
||||
|
||||
verify(exactly = 0) {
|
||||
workManager.enqueueUniqueWork(any<String>(), any<ExistingWorkPolicy>(), any<OneTimeWorkRequest>())
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import androidx.work.ListenableWorker.Result
|
||||
import eu.darken.capod.common.upgrade.core.billing.BillingManager.AckSweepResult
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class PurchaseAckWorkerTest : BaseTest() {
|
||||
|
||||
@Test fun `sweeping is only worth it before the refund deadline`() {
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 101L) shouldBe true
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 100L) shouldBe false
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 99L) shouldBe false
|
||||
// Malformed input data (missing/zero deadline) must not retry forever.
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 0L) shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `a complete sweep succeeds`() {
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.COMPLETE, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Success>()
|
||||
}
|
||||
|
||||
@Test fun `a permanently rejected ack stops the retries`() {
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.PERMANENT_FAILURE, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Failure>()
|
||||
}
|
||||
|
||||
@Test fun `transient outcomes retry until the deadline`() {
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.RETRY, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Retry>()
|
||||
// null = the sweep timed out: same transient treatment.
|
||||
PurchaseAckWorker.mapSweep(null, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Retry>()
|
||||
// Past the deadline Play has already refunded: give up visibly.
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.RETRY, now = 200L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Failure>()
|
||||
PurchaseAckWorker.mapSweep(null, now = 200L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Failure>()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user