From 33a972001e1418fa3cca67b27b42851163f327a3 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 16 Aug 2026 15:37:35 +0200 Subject: [PATCH] feat(upgrade): Carry pending Google Play purchases through the billing stack Play reports a purchase as PENDING while a slow payment method (cash, carrier billing, bank transfer) is still being processed. Until now BillingConnection dropped those at ingestion, so the app had no idea the user had bought anything: the upgrade screen kept selling, and a second purchase attempt was rejected by Play with ITEM_ALREADY_OWNED. Pending purchases now enter the reducer state and travel to the UI, while every entitlement exit stays PURCHASED-only: - BillingConnection ingests PURCHASED + PENDING (UNSPECIFIED_STATE is still dropped everywhere). The freshUpdates stream keeps receiving only PURCHASED, and provesAbsence now ignores a surviving PENDING overlay entry, so a payment in progress can't freeze the unconfirmed-episode clock. - combinePurchaseResults gets the sku-type resolver: a PENDING result only suppresses the couldn't-verify error when it maps to a known Pro SKU. An unknown pending product proves nothing about the type whose query failed. - PurchaseRefresh now carries provenance (confirmed set, hasConfirmedProPurchase, commit-time occurredAt, partialError) instead of just the merged view plus isComplete. - BillingData splits into purchases (entitlement carrier) and pendingPurchases via a single from() classifier used at every exit. - BillingManager gains processReconciliation(), run after the connect loop's initial refresh and by refresh(): it re-signals dead-binder invalidation and feeds the grace episode clock with the refresh's COMMIT time. The ack pass skips pending purchases, which Play rejects permanently and would report as a bug every pass. - BillingConnection.querySubscriptions / BillingManager.querySubscriptions are replaced by refreshStrict(): the pre-purchase gate needs both product types and the pending state, and still fails closed on anything short of a complete round-trip. - UpgradeRepoGplay exposes Info.pendingSkus (never part of isPro), Info.hasAutoRenewingSubscription, verifyPurchaseStateNow() for the gates, and reports PendingPurchaseBillingException when an already-owned recovery finds a pending payment. The grace branch now carries billingData through so pending stays visible while Pro runs on grace. --- .../common/upgrade/core/UpgradeRepoGplay.kt | 68 ++++++-- .../upgrade/core/billing/BillingData.kt | 25 ++- .../upgrade/core/billing/BillingManager.kt | 92 ++++++++--- .../PendingPurchaseBillingException.kt | 11 ++ .../billing/client/BillingClientExtensions.kt | 15 ++ .../core/billing/client/BillingConnection.kt | 152 ++++++++++-------- .../capod/common/upgrade/ui/UpgradeEvents.kt | 11 +- 7 files changed, 269 insertions(+), 105 deletions(-) create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/PendingPurchaseBillingException.kt 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 40cdd39a..3339ed4c 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 @@ -2,7 +2,6 @@ package eu.darken.capod.common.upgrade.core import android.app.Activity import com.android.billingclient.api.BillingClient.BillingResponseCode -import com.android.billingclient.api.Purchase import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.datastore.value import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR @@ -18,6 +17,7 @@ import eu.darken.capod.common.upgrade.core.billing.BillingData import eu.darken.capod.common.upgrade.core.billing.BillingManager import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException import eu.darken.capod.common.upgrade.core.billing.ItemAlreadyOwnedBillingException +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.Sku import eu.darken.capod.common.upgrade.core.billing.SkuDetails @@ -284,9 +284,18 @@ class UpgradeRepoGplay @Inject constructor( // Reconciled only if the restore actually returned the SKU Play claims is // owned — a grace-only isPro doesn't count, the entitlement is still missing. if (restored?.upgrades?.any { it.sku == sku } != true) { - // Couldn't reconcile the entitlement (pending purchase, account mismatch, - // Play quirk) — fall back to the already-owned dialog with restore tips. - onError(e) + if (restored?.pendingSkus?.isNotEmpty() == true) { + // Play blocks re-purchasing a product whose payment it is still + // processing. "Already owned" is technically what Play said, but the + // dialog's restore tips are the wrong advice: nothing to restore, and + // the entitlement lands by itself once the payment clears. + log(TAG, INFO) { "Already-owned recovery found a pending payment" } + onError(PendingPurchaseBillingException(e)) + } else { + // Couldn't reconcile the entitlement (account mismatch, Play quirk) — + // fall back to the already-owned dialog with restore tips. + onError(e) + } } } @@ -329,12 +338,15 @@ class UpgradeRepoGplay @Inject constructor( suspend fun querySkus(vararg skus: Sku): Collection = billingManager.querySkus(*skus) - // Strict subscription lookup for the pre-purchase gate: fresh SUBS-only query with explicit - // failure. No grace substitution and no cross-product-type tolerance (unlike refresh() and + // Strict purchase-state lookup for the pre-purchase gates: a fresh COMPLETE round-trip with + // explicit failure. No grace substitution and no partial tolerance (unlike refresh() and // restorePurchaseNow()) — callers must treat any error as "couldn't verify" and fail closed. - suspend fun queryCurrentSubscriptions(): Collection { - log(TAG) { "queryCurrentSubscriptions()" } - return billingManager.querySubscriptions() + // Covers both product types and pending payments, because both can make a purchase wrong: a + // renewing subscription (double billing) and a payment Play is still processing (Play rejects + // the re-purchase). + suspend fun verifyPurchaseStateNow(): Info { + log(TAG) { "verifyPurchaseStateNow()" } + return Info(billingData = billingManager.refreshStrict(), isSettled = true) } override suspend fun refresh() { @@ -496,8 +508,8 @@ class UpgradeRepoGplay @Inject constructor( // Shared Pro/grace mapping used by both the reactive upgradeInfo flow and restorePurchaseNow(). // Only relinquishes Pro if we haven't had it for a while (grace period). READ-ONLY: this runs on // replayed shared-flow data too, so it must never stamp the grace cache — see recordProState(). - // settled comes from the caller, never from billingData nullness: the grace branch returns an - // Info with billingData = null that may well be settled (built from a real empty snapshot). + // settled comes from the caller, never from billingData nullness: a null-data Info can be + // perfectly settled (a real empty snapshot), and the grace branch carries data through anyway. private suspend fun BillingData?.toUpgradeInfo(settled: Boolean): Info { // Branch on MAPPED upgrades, not raw purchases: a purchase list containing only products // this app doesn't know maps to zero upgrades and must fall through to the grace check — @@ -513,7 +525,11 @@ class UpgradeRepoGplay @Inject constructor( return when { (now - lastProStateAt) < graceWindowMs() -> { log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" } - Info(gracePeriod = true, billingData = null, isSettled = settled) + // billingData is carried through, not dropped: this branch is only reached when the + // mapped upgrades are empty, so entitlement stays empty either way — but a pending + // payment must stay visible, and a grace user waiting on one is exactly who needs + // the explanation. + Info(gracePeriod = true, billingData = this, isSettled = settled) } else -> mapped @@ -558,6 +574,34 @@ class UpgradeRepoGplay @Inject constructor( ?.flatten() ?: emptySet() + // Products with a payment Play is still processing. Deliberately NOT part of [isPro] or + // [upgrades]: a pending payment grants nothing. It exists so the UI can explain the wait + // and lock the purchase buttons — buying the alternative product now would double-charge. + val pendingSkus: Collection = billingData?.pendingPurchases + ?.map { purchase -> + purchase.products.mapNotNull { productId -> + val sku = OurSku.PRO_SKUS.singleOrNull { it.id == productId } + if (sku == null) { + log(TAG, WARN) { "Unknown pending product: $productId (${purchase.redacted()})" } + return@mapNotNull null + } + sku + } + } + ?.flatten() + ?: emptySet() + + // Any owned purchase Play still bills on a schedule. Deliberately computed from the RAW + // PURCHASED purchases instead of [upgrades]: the mapping drops products this app doesn't + // know, and the pre-purchase gate must keep blocking on a renewing subscription with an + // unknown or legacy product ID — being wrong there means billing the user twice for Pro. + // Both product types are scanned; a one-time purchase reports isAutoRenewing = false, so + // the broader input cannot produce a false positive. + // Computed on access, not in the initializer: an Info is built for every mapping pass, and + // only the purchase gate needs this. + val hasAutoRenewingSubscription: Boolean + get() = billingData?.purchases?.any { it.isAutoRenewing } == true + override val isPro: Boolean = upgrades.isNotEmpty() || gracePeriod override val upgradedAt: Instant? = upgrades diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingData.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingData.kt index dcf5485e..2b5a5167 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingData.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/BillingData.kt @@ -1,7 +1,28 @@ package eu.darken.capod.common.upgrade.core.billing import com.android.billingclient.api.Purchase +import eu.darken.capod.common.upgrade.core.billing.client.isPurchased +import eu.darken.capod.common.upgrade.core.billing.client.isRelevant +/** + * Play's purchase state, split by what it may be used for. [purchases] is the entitlement carrier + * and PURCHASED-only by construction, so no consumer can grant Pro (or stamp the grace cache) from + * a payment Play is still processing; [pendingPurchases] keeps that payment visible to the UI. + */ data class BillingData( - val purchases: Collection -) \ No newline at end of file + val purchases: Collection, + val pendingPurchases: Collection = emptyList(), +) { + + companion object { + // The one place raw Play data becomes a BillingData: splitting here (instead of at each + // consumer) is what keeps "pending never grants Pro" a property of the type. Anything + // that is neither PURCHASED nor PENDING is dropped — it is not an entitlement and not a + // payment in progress. + fun from(raw: Collection): BillingData { + val relevant = raw.filter { it.isRelevant } + val (purchased, pending) = relevant.partition { it.isPurchased } + return BillingData(purchases = purchased, pendingPurchases = pending) + } + } +} 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 3d5df3c0..375bb50b 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 @@ -14,6 +14,7 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers 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.client.isPurchased import eu.darken.capod.common.upgrade.core.billing.client.redacted import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -73,13 +74,13 @@ class BillingManager @Inject constructor( private val failedOnce = MutableStateFlow(false) val isFailureSettled: Flow = failedOnce - // Fires once per failed connect-loop iteration: connection setup failure, the mandatory initial - // refreshPurchases erroring or timing out, an established connection dropping, an action-level - // invalidation (SERVICE_DISCONNECTED/SERVICE_TIMEOUT from any useConnection call), or an - // unexpected provider completion. Every one is a fresh reconciliation that couldn't confirm Pro. - // The connect loop retries these internally and downstream flows just go quiet, so without this - // explicit signal the grace episode clock (UpgradeRepoGplay.proUnconfirmedSince) would only - // advance on an explicit ON_RESUME refresh(). + // Fires once per reconciliation that couldn't confirm Pro: a failed connect-loop iteration + // (connection setup failure, the mandatory initial refreshPurchases erroring or timing out, an + // established connection dropping, an action-level invalidation, an unexpected provider + // completion) — and, via processReconciliation, a refresh that COMPLETED but only partially, + // without a confirmed Pro purchase. The connect loop retries its failures internally and + // downstream flows just go quiet, so without this explicit signal the grace episode clock + // (UpgradeRepoGplay.proUnconfirmedSince) would only advance on an explicit ON_RESUME refresh(). // // Each value is the failure's OCCURRENCE time (epoch millis). It has to be, not a bare Unit: the // channel buffers, and this feed and freshBillingData are separate flows with no cross-stream @@ -121,13 +122,18 @@ class BillingManager @Inject constructor( // isFailureSettled forever with no retry. withTimeoutOrNull, NOT // withTimeout: TimeoutCancellationException is a // CancellationException and would kill this loop. - withTimeoutOrNull(INITIAL_REFRESH_TIMEOUT_MS) { + val initialRefresh = withTimeoutOrNull(INITIAL_REFRESH_TIMEOUT_MS) { connection.refreshPurchases() } ?: throw BillingException("Initial purchase refresh timed out") failStreak = 0 connectionHolder.value = connection log(TAG, INFO) { "Billing connection established" } + // AFTER publishing: a partial refresh is still a usable connection + // (a pending-only cold start must not starve billingData), but its + // bookkeeping — episode clock, dead-binder teardown — has to run, + // and an invalidation may only tear down an INSTALLED connection. + processReconciliation(initialRefresh) } // The provider flow stays open for the connection's lifetime; a normal // completion means the connection is gone without an error — treat it @@ -214,7 +220,7 @@ class BillingManager @Inject constructor( .shareIn(scope, WhileSubscribed(3000L, 0L), replay = 1) val billingData: Flow = purchases - .map { BillingData(purchases = it) } + .map { BillingData.from(it) } .shareIn(scope, WhileSubscribed(3000L, 0L), replay = 1) val purchaseFailures: Flow = connectionHolder @@ -235,7 +241,10 @@ class BillingManager @Inject constructor( // every emission here is a real Play round-trip the grace bookkeeping needs. .resubscribeOnFailure("freshBillingData") } - .map { FreshData(data = BillingData(purchases = it.purchases), isFullSnapshot = it.isFullSnapshot, occurredAt = it.occurredAt) } + // Through from() like every other exit, although the connection only ever puts PURCHASED + // purchases on this stream: if that invariant ever broke, splitting keeps the grace + // bookkeeping from stamping a pending payment as a confirmation. + .map { FreshData(data = BillingData.from(it.purchases), isFullSnapshot = it.isFullSnapshot, occurredAt = it.occurredAt) } .setupCommonEventHandlers(TAG) { "freshBillingData" } // Same belt as `purchases`: an Eagerly shared flow that dies stays dead, and this one feeds // both the grace bookkeeping and the ack collector's re-drive signal. @@ -292,6 +301,14 @@ class BillingManager @Inject constructor( // -data signals. private suspend fun runAckPass(purchases: Collection) { 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, + // every pass — and there is nothing to acknowledge until the payment completes anyway. + if (!it.isPurchased) { + log(TAG) { "Not acknowledgeable yet: ${it.redacted()}" } + return@filter false + } + val needsAck = !it.isAcknowledged if (needsAck) log(TAG) { "Needs ACK: ${it.redacted()}" } @@ -401,6 +418,35 @@ 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. + val clientError = refresh.partialError?.let { + (it as? BillingClientException) ?: (it.cause as? BillingClientException) + } + if (clientError != null && clientError.result.responseCode in INVALIDATING_CODES) { + log(TAG, WARN) { "Refresh reported the connection dead (${clientError.result.responseCode}), invalidating." } + invalidations.trySend(Unit) + } + + if (!refresh.isComplete && !refresh.hasConfirmedProPurchase) { + // A reconciliation that couldn't confirm Pro. Stamped with the refresh's COMMIT time, + // never now-at-send: a confirmation that committed between this refresh and the send + // (e.g. a pending payment completing) must stay NEWER than this failure, or the grace + // episode it closed would be reopened. + log(TAG, WARN) { "Partial refresh without a confirmed Pro purchase at ${refresh.occurredAt}" } + connectionFailuresChannel.trySend(refresh.occurredAt) + } + } + private suspend fun useConnection(action: suspend BillingConnection.() -> T): T { // Every caller here is active demand (opening the upgrade screen, restore/buy taps, // purchase acks) — cut a pending reconnect backoff short. A no-op while healthy. @@ -479,18 +525,24 @@ class BillingManager @Inject constructor( // shared upgradeInfo replay cache. The freshBillingData emission happens inside the // reducer's commit, in commit order — not here. val fresh = useConnection { refreshPurchases() } - return BillingData(purchases = fresh.purchases) + processReconciliation(fresh) + return BillingData.from(fresh.purchases) } - // Strict SUBS-only query for the pre-purchase subscription gate: unlike refresh(), a failure - // here propagates (user-friendly-mapped) instead of being masked by the other product type. - suspend fun querySubscriptions(): Collection = try { - useConnection { querySubscriptions() } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log(TAG, WARN) { "querySubscriptions() failed: ${e.asLog()}" } - throw e.tryMapUserFriendly() + // Strict variant for the pre-purchase gates: unlike refresh(), anything short of a COMPLETE + // reconciliation throws (user-friendly-mapped) instead of returning what it happened to find — + // a gate must be able to tell "not owned" apart from "couldn't verify" and fail closed. + suspend fun refreshStrict(): BillingData { + log(TAG) { "refreshStrict()" } + val fresh = useConnection { refreshPurchases() } + 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. + val error = fresh.partialError ?: BillingException("Purchase refresh was incomplete") + log(TAG, WARN) { "refreshStrict() incomplete: ${error.asLog()}" } + throw error.tryMapUserFriendly() + } + return BillingData.from(fresh.purchases) } companion object { diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/PendingPurchaseBillingException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/PendingPurchaseBillingException.kt new file mode 100644 index 00000000..88669661 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/PendingPurchaseBillingException.kt @@ -0,0 +1,11 @@ +package eu.darken.capod.common.upgrade.core.billing + +/** + * A purchase can't proceed because the account already has a payment Play is still processing. + * + * Typed so the UI can answer with the informational pending dialog instead of the already-owned + * error and its restore tips: restoring cannot help — Play refuses to re-sell a product with a + * pending payment, and the entitlement arrives on its own once the payment clears. + */ +class PendingPurchaseBillingException(cause: Throwable? = null) : + BillingException("A purchase with a pending payment already exists.", cause) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingClientExtensions.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingClientExtensions.kt index 9c5d0e19..4126ad73 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingClientExtensions.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingClientExtensions.kt @@ -8,6 +8,21 @@ import com.android.billingclient.api.Purchase internal val BillingResult.isSuccess: Boolean get() = responseCode == BillingClient.BillingResponseCode.OK +/** + * Owned right now. The ONLY state that may grant an entitlement, stamp the Pro grace cache or be + * acknowledged — a PENDING purchase is a payment Play is still processing, and acknowledging one is + * rejected permanently. + */ +internal val Purchase.isPurchased: Boolean + get() = purchaseState == Purchase.PurchaseState.PURCHASED + +/** + * Worth carrying in our state at all: owned, or a payment in progress the user should see. Anything + * else (UNSPECIFIED_STATE) is dropped at ingestion — it is neither. + */ +internal val Purchase.isRelevant: Boolean + get() = isPurchased || purchaseState == Purchase.PurchaseState.PENDING + /** * Log-safe rendering of a [Purchase]. * diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnection.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnection.kt index b051e27b..e8ec255c 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnection.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnection.kt @@ -7,7 +7,6 @@ import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.Purchase -import com.android.billingclient.api.Purchase.PurchaseState import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsResult import com.android.billingclient.api.QueryPurchasesParams @@ -41,11 +40,11 @@ class BillingConnection( private val skuTypeOf: (String) -> Sku.Type? = DEFAULT_SKU_TYPE_RESOLVER, ) { - // A purchase proven by an onPurchasesUpdated success event. Additive only: events prove - // ownership, never absence. `gen` orders it against queries (a query that STARTED before this - // event must not clear it); `type` is resolved at ingestion so a later per-type query that - // confirms absence can supersede it (null = product unknown to this app, only a complete - // refresh may clear it). + // A purchase (owned or with a pending payment) proven by an onPurchasesUpdated success event. + // Additive only: events prove existence, never absence. `gen` orders it against queries (a + // query that STARTED before this event must not clear it); `type` is resolved at ingestion so a + // later per-type query that confirms absence can supersede it (null = product unknown to this + // app, only a complete refresh may clear it). data class OverlayEntry( val purchase: Purchase, val gen: Long, @@ -64,11 +63,11 @@ class BillingConnection( ) { internal fun withEvent( - purchased: Collection, + relevant: Collection, typeOf: (String) -> Sku.Type?, ): ReducerState { val gen = eventGen + 1 - val entries = purchased.map { purchase -> + val entries = relevant.map { purchase -> OverlayEntry( purchase = purchase, gen = gen, @@ -169,10 +168,13 @@ class BillingConnection( "onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, " + "purchases=${purchases?.redacted()})" } - // PENDING purchases must never surface as owned (or stamp the Pro grace cache). - val purchased = purchases.orEmpty().filter { it.purchaseState == PurchaseState.PURCHASED } + // The reducer carries PENDING purchases too (the UI must be able to show a payment in + // progress), but the fresh stream stays PURCHASED-only: it feeds the entitlement and + // grace bookkeeping, which must never see a payment Play hasn't completed. + val relevant = purchases.orEmpty().filter { it.isRelevant } + val purchased = relevant.filter { it.isPurchased } synchronized(reducerLock) { - state.value = state.value.withEvent(purchased, skuTypeOf) + state.value = state.value.withEvent(relevant, skuTypeOf) if (purchased.isNotEmpty()) { freshUpdatesChannel.trySend(FreshUpdate(purchased, isFullSnapshot = false)) } @@ -193,12 +195,29 @@ class BillingConnection( failureChannel.close() } - // The purchases of a refresh plus whether it covered both product types: a partial result (one - // query failed) is still authoritative for what it FOUND, but must not be treated as proof of - // absence for the type that couldn't be checked. + // The full outcome of a refresh: what it committed, what it actually CONFIRMED, and how far it + // got. A partial result (one query failed) is still authoritative for what it found, but must + // not be treated as proof of absence for the type that couldn't be checked — so callers that + // need to fail closed, or to feed the grace episode clock, get the provenance instead of having + // to infer it from the merged view. data class PurchaseRefresh( + // The committed reducer state (queries merged with retained snapshots and surviving + // events) — the same view the reactive purchases flow emits. val purchases: Collection, + // ONLY what these queries returned: never retained state of a failed type, so a consumer + // can tell "Play said so just now" from "we still remember this". + val confirmed: Collection = emptyList(), + // A confirmed PURCHASED purchase of a product this app knows. Fail-safe default: "we + // couldn't confirm Pro" is the direction that keeps the grace bookkeeping honest. + val hasConfirmedProPurchase: Boolean = false, val isComplete: Boolean, + // Commit time under reducerLock — the same instant stamped on this refresh's FreshUpdate, + // so a confirmation and a later failure signal are ordered by when they HAPPENED. + val occurredAt: Long = System.currentTimeMillis(), + // Why the refresh is incomplete (the failed type's already user-friendly-mapped + // exception), null when complete. Carried rather than thrown: a partial refresh that found + // something is still useful, only the caller can decide whether partial is good enough. + val partialError: Throwable? = null, ) // Serializes concurrent refreshes (manual, background, auto-restore): an older query that got @@ -214,8 +233,8 @@ class BillingConnection( coroutineScope { log(TAG) { "refreshPurchases()" } val genAtQueryStart = state.value.eventGen - val iapJob = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) } - val subJob = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) } + val iapJob = async { queryRelevantProducts(BillingClient.ProductType.INAPP) } + val subJob = async { queryRelevantProducts(BillingClient.ProductType.SUBS) } val iap = iapJob.await() val sub = subJob.await() log(TAG) { "Refreshed IAPs=${iap.getOrNull()?.redacted()}, SUBs=${sub.getOrNull()?.redacted()}" } @@ -224,6 +243,12 @@ class BillingConnection( // authoritative even when its sibling failed — verified absence (e.g. a refunded IAP) // must not be discarded just because the SUB query errored. val isComplete = iap.isSuccess && sub.isSuccess + // Only what the queries CONFIRMED as owned — retained stale data of a failed type, and + // pending payments, stay out (both would keep re-stamping the grace window). + val confirmed = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()) + .filter { it.isPurchased } + .sortedByDescending { it.purchaseTime } + var committedAt = 0L val committed = synchronized(reducerLock) { val next = state.value.withQueryResults( iap = iap.getOrNull(), @@ -231,17 +256,18 @@ class BillingConnection( genAtQueryStart = genAtQueryStart, ) state.value = next + committedAt = System.currentTimeMillis() if (iap.isSuccess || sub.isSuccess) { - // Only what the queries CONFIRMED — retained stale data of a failed type stays - // out of the fresh stream (it would keep re-stamping the grace window). - val confirmed = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()) - .sortedByDescending { it.purchaseTime } - // A surviving overlay entry (purchase event newer than the query start, or of - // a failed type) means this result does NOT prove total absence: it must not - // count as a full snapshot, or an empty query racing a fresh purchase event - // would start a false unconfirmed-grace episode. - val provesAbsence = isComplete && next.overlay.isEmpty() - freshUpdatesChannel.trySend(FreshUpdate(confirmed, isFullSnapshot = provesAbsence)) + // A surviving OWNED overlay entry (purchase event newer than the query start, + // or of a failed type) means this result does NOT prove total absence: it must + // not count as a full snapshot, or an empty query racing a fresh purchase event + // would start a false unconfirmed-grace episode. A surviving PENDING entry + // proves nothing about ownership, so it must not suppress the bookkeeping + // either — a payment in progress would otherwise freeze the episode clock. + val provesAbsence = isComplete && next.overlay.none { it.purchase.isPurchased } + freshUpdatesChannel.trySend( + FreshUpdate(confirmed, isFullSnapshot = provesAbsence, occurredAt = committedAt) + ) } next } @@ -249,31 +275,42 @@ class BillingConnection( // Support-log anchor, at INFO because purchase complaints arrive as debug recordings. // Logs what these queries CONFIRMED, kept distinct from the committed view: merged() // retains a failed type's previous purchases, so reporting it as "what Play returned" - // would be the same false-certainty trap the copy elsewhere had to fix. Product IDs - // only -- never the Purchase, which carries order and token data. + // would be the same false-certainty trap the copy elsewhere had to fix. Pending ids are + // listed separately — "bought it, still not Pro" reports are exactly this state. + // Product IDs only -- never the Purchase, which carries order and token data. log(TAG, INFO) { - val confirmedIds = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()).flatMap { it.products } - "refreshPurchases(): confirmed=$confirmedIds, isComplete=$isComplete, " + + val returned = iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty() + val confirmedIds = returned.filter { it.isPurchased }.flatMap { it.products } + val pendingIds = returned.filterNot { it.isPurchased }.flatMap { it.products } + "refreshPurchases(): confirmed=$confirmedIds, pending=$pendingIds, isComplete=$isComplete, " + "iapOk=${iap.isSuccess}, subOk=${sub.isSuccess}, merged=${committed.merged().size}" } // Throws when nothing was found and a query failed, so the caller can tell "not // owned" apart from "couldn't verify". - combinePurchaseResults(iap, sub) + combinePurchaseResults(iap, sub, skuTypeOf) PurchaseRefresh( purchases = committed.merged(), + confirmed = confirmed, + hasConfirmedProPurchase = confirmed.any { purchase -> + purchase.products.any { skuTypeOf(it) != null } + }, isComplete = isComplete, + occurredAt = committedAt, + partialError = iap.exceptionOrNull() ?: sub.exceptionOrNull(), ) } } // Never throws except on cancellation, so a single failing product-type query doesn't cancel // the sibling query (or the coroutineScope). The exception is already user-friendly-mapped. - private suspend fun queryPurchasedProducts( + // Returns owned AND pending purchases; the split by state happens where it matters (fresh + // stream, entitlement mapping), so a pending payment stays visible instead of vanishing here. + private suspend fun queryRelevantProducts( @BillingClient.ProductType type: String, ): Result> = try { - Result.success(queryPurchases(type).filter { it.purchaseState == PurchaseState.PURCHASED }) + Result.success(queryPurchases(type).filter { it.isRelevant }) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -307,37 +344,6 @@ class BillingConnection( return purchaseData } - // Strict SUBS-only query for the pre-purchase subscription gate: unlike refreshPurchases(), - // a failure propagates (no cross-type tolerance) — callers must be able to fail closed on - // "couldn't verify". Commits through the reducer like any query, so the reactive purchases - // flow picks up the fresh renewal state, and emits a partial fresh update: it proves what the - // SUBS query found, never the absence of anything it didn't cover. - suspend fun querySubscriptions(): Collection = refreshMutex.withLock { - log(TAG) { "querySubscriptions()" } - val genAtQueryStart = state.value.eventGen - val subs = queryPurchases(BillingClient.ProductType.SUBS) - .filter { it.purchaseState == PurchaseState.PURCHASED } - val committed = synchronized(reducerLock) { - val next = state.value.withQueryResults( - iap = null, - sub = subs, - genAtQueryStart = genAtQueryStart, - ) - state.value = next - freshUpdatesChannel.trySend(FreshUpdate(subs, isFullSnapshot = false)) - next - } - // The COMMITTED view, not the raw response: a purchase event that arrived after the query - // started survives the commit as a newer overlay and must reach the gate too — otherwise a - // just-purchased renewing sub could slip past the fail-closed double-billing check. - // Non-IAP overlays only; untyped (unknown product) entries stay in on the safe side. - val byToken = LinkedHashMap() - subs.forEach { byToken[it.purchaseToken] = it } - committed.overlay - .filter { it.type != Sku.Type.IAP } - .forEach { byToken[it.purchase.purchaseToken] = it.purchase } - byToken.values.sortedByDescending { it.purchaseTime } - } suspend fun acknowledgePurchase(purchase: Purchase): BillingResult { val ack = AcknowledgePurchaseParams.newBuilder().apply { setPurchaseToken(purchase.purchaseToken) @@ -529,16 +535,22 @@ class BillingConnection( OurSku.PRO_SKUS.singleOrNull { it.id == productId }?.type } - // Combines the two product-type query results: a purchase found by either type is - // authoritative; an error is only propagated when nothing was found, so callers can tell - // "not owned" apart from "couldn't verify one product type". Treating any found purchase - // as authoritative is safe because every product this app sells is a Pro SKU (see - // OurSku.PRO_SKUS). Pure and unit-tested. + // Combines the two product-type query results: an error is only propagated when the refresh + // learned nothing usable, so callers can tell "not owned" apart from "couldn't verify one + // product type". A PURCHASED result of ANY product suppresses the error — every product + // this app sells is a Pro SKU (see OurSku.PRO_SKUS), so it is by construction relevant. A + // PENDING result only counts when it maps to a KNOWN Pro SKU: it grants nothing, and an + // unknown pending product says nothing about the type whose query failed, so treating it + // as a find would swallow a real "couldn't verify". Pure and unit-tested. internal fun combinePurchaseResults( iap: Result>, sub: Result>, + typeOf: (String) -> Sku.Type? = DEFAULT_SKU_TYPE_RESOLVER, ): Collection { - val found = iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty() + val returned = iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty() + val found = returned.filter { purchase -> + purchase.isPurchased || purchase.products.any { typeOf(it) != null } + } return when { found.isNotEmpty() -> found.sortedByDescending { it.purchaseTime } else -> { diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeEvents.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeEvents.kt index 1086b6d1..b10e416f 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeEvents.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/ui/UpgradeEvents.kt @@ -13,5 +13,14 @@ sealed class UpgradeEvents { */ data object RestoreInconclusive : UpgradeEvents() data object SubscriptionStillRenewing : UpgradeEvents() - data object SubscriptionCheckFailed : UpgradeEvents() + + /** + * Play answered, no completed purchase exists, but a payment is still being processed. Purely + * informational: nothing to fix, nothing to restore — Pro unlocks by itself once Play clears + * the payment, and a new purchase would be rejected (or double-charge) in the meantime. + */ + data object PurchasePending : UpgradeEvents() + + /** The pre-purchase check with Play didn't finish, so the purchase was not started. */ + data object PurchaseCheckFailed : UpgradeEvents() }