mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 {
|
||||
|
||||
+11
@@ -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)
|
||||
+15
@@ -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].
|
||||
*
|
||||
|
||||
+82
-70
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user