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.
This commit is contained in:
darken
2026-08-16 17:44:02 +02:00
committed by Matthias Urhahn
parent fe67ce0365
commit 33a972001e
7 changed files with 269 additions and 105 deletions
@@ -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<SkuDetails> = 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<Purchase> {
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<Sku> = 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
@@ -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<Purchase>
)
val purchases: Collection<Purchase>,
val pendingPurchases: Collection<Purchase> = 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<Purchase>): BillingData {
val relevant = raw.filter { it.isRelevant }
val (purchased, pending) = relevant.partition { it.isPurchased }
return BillingData(purchases = purchased, pendingPurchases = pending)
}
}
}
@@ -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<Boolean> = 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<BillingData> = purchases
.map { BillingData(purchases = it) }
.map { BillingData.from(it) }
.shareIn(scope, WhileSubscribed(3000L, 0L), replay = 1)
val purchaseFailures: Flow<BillingResult> = 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<Purchase>) {
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 <T> 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<Purchase> = 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 {
@@ -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)
@@ -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].
*
@@ -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<Purchase>,
relevant: Collection<Purchase>,
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<Purchase>,
// 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<Purchase> = 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<Collection<Purchase>> = 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<Purchase> = 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<String, Purchase>()
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<Collection<Purchase>>,
sub: Result<Collection<Purchase>>,
typeOf: (String) -> Sku.Type? = DEFAULT_SKU_TYPE_RESOLVER,
): Collection<Purchase> {
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 -> {
@@ -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()
}