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 375bb50b..8fc81ab1 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 @@ -418,17 +418,12 @@ class BillingManager @Inject constructor( } } - // Everything a COMPLETED refresh owes the rest of the app, in one place: both the connect loop's - // initial refresh and manual refresh() calls run through it, so a Restore tap during an outage - // feeds the same bookkeeping the connect loop does. Only reached when refreshPurchases returned - // (it still throws when it found nothing AND a query failed — that path is the connect loop's / - // useConnection's). - private fun processReconciliation(refresh: BillingConnection.PurchaseRefresh) { - // A partial refresh no longer reaches useConnection's dead-binder detection (it returns - // instead of throwing), so the teardown that used to ride the throw path happens here. - // Cause chain, not the exception itself: the failure arrives user-friendly-mapped. - // Deliberately no holder CAS: the failing connection may already have been replaced, and - // the accepted cost of that rare race is one extra failed action while the loop reconnects. + // A partial refresh no longer reaches useConnection's dead-binder detection (it returns instead + // of throwing), so the teardown that used to ride the throw path happens here. Cause chain, not + // the exception itself: the failure arrives user-friendly-mapped. Deliberately no holder CAS: + // the failing connection may already have been replaced, and the accepted cost of that rare + // race is one extra failed action while the loop reconnects. + private fun invalidateOnDeadConnection(refresh: BillingConnection.PurchaseRefresh) { val clientError = refresh.partialError?.let { (it as? BillingClientException) ?: (it.cause as? BillingClientException) } @@ -436,6 +431,15 @@ class BillingManager @Inject constructor( log(TAG, WARN) { "Refresh reported the connection dead (${clientError.result.responseCode}), invalidating." } invalidations.trySend(Unit) } + } + + // Everything a COMPLETED refresh owes the rest of the app, in one place: both the connect loop's + // initial refresh and manual refresh() calls run through it, so a Restore tap during an outage + // feeds the same bookkeeping the connect loop does. Only reached when refreshPurchases returned + // (it still throws when it found nothing AND a query failed — that path is the connect loop's / + // useConnection's). + private fun processReconciliation(refresh: BillingConnection.PurchaseRefresh) { + invalidateOnDeadConnection(refresh) if (!refresh.isComplete && !refresh.hasConfirmedProPurchase) { // A reconciliation that couldn't confirm Pro. Stamped with the refresh's COMMIT time, @@ -535,6 +539,11 @@ class BillingManager @Inject constructor( suspend fun refreshStrict(): BillingData { log(TAG) { "refreshStrict()" } val fresh = useConnection { refreshPurchases() } + // A gate that hit a dying connection must still trigger the reconnect (the throw below + // bypasses useConnection's detection, which already returned), or every later purchase + // check keeps reusing the corpse. No-op on a complete refresh. The episode clock stays out + // of this path: an aborted gate is not a reconciliation outcome. + invalidateOnDeadConnection(fresh) if (!fresh.isComplete) { // partialError is set for every incomplete refresh; the fallback only exists so a // future incompleteness without a captured cause still fails closed instead of passing. 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 97b0bbf9..6e1dbe9a 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 @@ -14,6 +14,7 @@ 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 io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.longs.shouldBeLessThan import io.kotest.matchers.shouldBe import io.mockk.coEvery @@ -730,6 +731,54 @@ class BillingManagerTest : BaseTest() { failures shouldBe emptyList() } + @Test fun `a strict gate failure on a dead connection still tears the connection down`() = runTest2 { + // The gate's throw happens after useConnection already returned, so its dead-binder + // detection can't fire — without the explicit invalidation connection 1 stays installed and + // every later purchase check runs against the corpse. Feeding the episode clock still stays + // off this path. + val owned = purchase() + val dead = connection( + refreshes = listOf( + completeRefresh(), + partialRefresh( + occurredAt = 4242L, + error = GplayServiceUnavailableException( + BillingClientException(result(BillingResponseCode.SERVICE_DISCONNECTED)) + ), + ), + ), + ) + val good = connection(refreshResults = listOf(emptyList(), listOf(owned))) + var attempts = 0 + val provider = mockk().apply { + every { this@apply.connection } returns flow { + attempts++ + emit(if (attempts == 1) dead else good) + awaitCancellation() + } + } + val manager = manager(provider) + val failures = collectFailures(manager) + runCurrent() // connection 1 established + + shouldThrow { manager.refreshStrict() } + // Teardown takes several dispatches and runs on backgroundScope — runCurrent, never + // advanceUntilIdle, which would leave the connect loop untouched. + runCurrent() + + // The next action is fresh demand: it skips the reconnect backoff and lands on connection 2. + val refreshed = async { manager.refresh() } + advanceUntilIdle() + refreshed.await() shouldBe BillingData(listOf(owned)) + attempts shouldBe 2 + + runCurrent() + // The torn-down connection is a failed loop iteration (wall-clock stamped), but the gate's + // own partial refresh must never reach the feed with its commit time. + failures.isNotEmpty() shouldBe true + failures shouldNotContain 4242L + } + @Test fun `a partial reconciliation without a confirmed pro purchase signals its commit time`() = runTest2 { // The pending-only cold start: Play answered for one type with a payment in progress, the // other failed. Nothing confirms Pro, so the grace episode clock must advance — stamped