diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d64f1918..39303505 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -189,6 +189,7 @@ dependencies { addCompose() addGlance() + addWorkerManager() addDataStore() addNavigation3() addSerialization() diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index 3339ed4c..57b55d37 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -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, diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManager.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManager.kt index 8fc81ab1..0259fdc4 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManager.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManager.kt @@ -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() // 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() // 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) { + private suspend fun runAckPass(purchases: Collection): 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 diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckScheduler.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckScheduler.kt new file mode 100644 index 00000000..f55c2dbb --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckScheduler.kt @@ -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, +) { + + // 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().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") + } +} diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckWorker.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckWorker.kt new file mode 100644 index 00000000..79a7bd1f --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckWorker.kt @@ -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") + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 385a54d7..9ae1a694 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -132,6 +132,19 @@ + + + + + + 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") } diff --git a/app/src/main/java/eu/darken/capod/common/worker/WorkManagerModule.kt b/app/src/main/java/eu/darken/capod/common/worker/WorkManagerModule.kt new file mode 100644 index 00000000..f6eb8494 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/worker/WorkManagerModule.kt @@ -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) +} diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt index ab47ba70..495d0d46 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt @@ -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() private val billingCache = mockk() private val curriculumVitae = mockk(relaxed = true) + private val ackScheduler = mockk(relaxed = true) private lateinit var lastProAtMock: DataStoreValue private lateinit var lastProSkuMock: DataStoreValue private lateinit var proUnconfirmedMock: DataStoreValue @@ -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() + 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() + 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 } diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt index 6e1dbe9a..0f23d448 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt @@ -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(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() + coEvery { ackScheduler.armForUnackedPurchases(any()) } coAnswers { order.add("arm:${firstArg()}") } + 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 } diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckSchedulerTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckSchedulerTest.kt new file mode 100644 index 00000000..e7b7348c --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckSchedulerTest.kt @@ -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>().apply { + every { isDone } returns true + every { get() } returns mockk() + } + private val operation = mockk().apply { + every { result } returns enqueueFuture + } + private val workManager = mockk().apply { + every { + enqueueUniqueWork(any(), any(), any()) + } 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() + val policy = slot() + verify(exactly = 1) { + workManager.enqueueUniqueWork(capture(name), capture(policy), any()) + } + 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() + val policy = slot() + verify(exactly = 1) { + workManager.enqueueUniqueWork(capture(name), capture(policy), any()) + } + // 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(), any(), any()) + } + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckWorkerTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckWorkerTest.kt new file mode 100644 index 00000000..7d17f39f --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/work/PurchaseAckWorkerTest.kt @@ -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() + } + + @Test fun `a permanently rejected ack stops the retries`() { + PurchaseAckWorker.mapSweep(AckSweepResult.PERMANENT_FAILURE, now = 100L, expiresAt = 200L) + .shouldBeInstanceOf() + } + + @Test fun `transient outcomes retry until the deadline`() { + PurchaseAckWorker.mapSweep(AckSweepResult.RETRY, now = 100L, expiresAt = 200L) + .shouldBeInstanceOf() + // null = the sweep timed out: same transient treatment. + PurchaseAckWorker.mapSweep(null, now = 100L, expiresAt = 200L) + .shouldBeInstanceOf() + // Past the deadline Play has already refunded: give up visibly. + PurchaseAckWorker.mapSweep(AckSweepResult.RETRY, now = 200L, expiresAt = 200L) + .shouldBeInstanceOf() + PurchaseAckWorker.mapSweep(null, now = 200L, expiresAt = 200L) + .shouldBeInstanceOf() + } +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 9da7dcca..651b908d 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -130,6 +130,22 @@ fun DependencyHandlerScope.addDataStore() { implementation("androidx.datastore:datastore-preferences:1.1.4") } +fun DependencyHandlerScope.addWorkerManager() { + // Resolved transitively via Glance today; declared explicitly so the safety-net worker does not + // depend on Glance's choice. work-runtime-ktx is NOT an empty shell at this version: at 2.7.1 + // CoroutineWorker, OperationKt.await, OneTimeWorkRequestBuilder and workDataOf all live in the + // ktx artifact (they only moved into work-runtime on later releases). + val version = "2.7.1" + implementation("androidx.work:work-runtime:$version") + implementation("androidx.work:work-runtime-ktx:$version") + testImplementation("androidx.work:work-testing:$version") + + // androidx.hilt 1.0.0's hilt-compiler ships no KSP SymbolProcessorProvider, so @HiltWorker + // would silently generate nothing under this project's KSP setup. + implementation("androidx.hilt:hilt-work:1.2.0") + ksp("androidx.hilt:hilt-compiler:1.2.0") +} + fun DependencyHandlerScope.addGlance() { implementation("androidx.glance:glance-appwidget:${Versions.Glance.core}") implementation("androidx.glance:glance-material3:${Versions.Glance.core}")