fix(upgrade): Harden billing storage, restore races, and offer retry

This commit is contained in:
darken
2026-07-23 13:01:14 +02:00
parent f629f05f23
commit 36c54d5d19
10 changed files with 603 additions and 52 deletions
@@ -24,8 +24,10 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -44,7 +47,6 @@ import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
@Singleton
class UpgradeRepoGplay @Inject constructor(
@@ -54,14 +56,18 @@ class UpgradeRepoGplay @Inject constructor(
private val timeSource: TimeSource,
) : UpgradeRepo {
private val lastProStateAt: Long
get() = billingCache.lastProStateAt.valueBlocking
// Serializes the sticky check-then-write anchor logic: concurrent fresh observations (init
// collector, direct restores, failure events) must not interleave between reading the current
// anchor and stamping the new one.
private val proStateLock = Mutex()
// True while the invisible already-owned recovery (the async ITEM_ALREADY_OWNED collector below)
// is restoring. The ViewModel gates buy actions on it so a buy tap can't race the silent restore
// and buy the OTHER product on top of what the user already owns (a different-SKU double charge —
// the ITEM_ALREADY_OWNED reconciliation only covers the same SKU).
private val autoRestoreBusyState = MutableStateFlow(false)
val autoRestoreBusy: StateFlow<Boolean> = autoRestoreBusyState.asStateFlow()
init {
// Fresh-provenance grace stamping: freshBillingData carries every successful query result
// and push payload as an event — unlike the equality-deduped billingData state, an
@@ -107,12 +113,15 @@ class UpgradeRepoGplay @Inject constructor(
.filter { it.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED }
.onEach {
log(TAG, INFO) { "Async already-owned event -> restoring purchase" }
autoRestoreBusyState.value = true
try {
withTimeoutOrNull(RESTORE_ON_OWNED_TIMEOUT_MS) { restorePurchaseNow() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Async already-owned restore failed: ${e.asLog()}" }
} finally {
autoRestoreBusyState.value = false
}
}
.setupCommonEventHandlers(TAG) { "asyncAlreadyOwned" }
@@ -121,9 +130,36 @@ class UpgradeRepoGplay @Inject constructor(
// Grace window depends on what was last owned: a permanent one-time purchase should almost
// never be dropped on a Play hiccup, so it gets a long window; a subscription legitimately
// lapses, so it keeps the short one (also used for unknown/legacy last SKUs).
private fun graceWindowMs(): Long =
if (billingCache.lastProStateSku.valueBlocking.isIapSku()) GRACE_PERIOD_IAP_MS else GRACE_PERIOD_MS
// lapses, so it keeps the short one (also used for unknown/legacy last SKUs). Suspend + the
// cancellable value() read (not valueBlocking's runBlocking) so a hung DataStore read can be
// cancelled/retried instead of pinning a dispatcher thread.
private suspend fun graceWindowMs(): Long =
if (billingCache.lastProStateSku.value().isIapSku()) GRACE_PERIOD_IAP_MS else GRACE_PERIOD_MS
// Was the last confirmed Pro state within the grace window? Guarded AND bounded: a read failure
// — the same DataStore a caller may have just failed on — is treated as "not recently Pro"
// rather than propagating; a hung read is bounded by a timeout so it can't wedge the sequential
// upgradeInfo mapping and block a later confirmed purchase behind it. Shared by the reactive
// mapping, the reactive retry and the direct restore so their grace decision can't diverge.
private suspend fun isRecentlyPro(): Boolean = try {
val recent = withTimeoutOrNull(GRACE_PROBE_TIMEOUT_MS) {
(timeSource.currentTimeMillis() - billingCache.lastProStateAt.value()) < graceWindowMs()
}
if (recent == null) log(TAG, WARN) { "Grace probe timed out, treating as not-recently-pro" }
recent ?: false
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Grace probe read failed, treating as not-recently-pro: ${e.asLog()}" }
false
}
// Reactive fallback when the upgradeInfo mapping throws (only local DataStore reads can fail here
// now — the connection loop retries billing errors itself): keep a recently-Pro user in grace,
// otherwise surface the error. Never throws — a second cache failure resolves to the error Info.
private suspend fun graceOrError(error: Throwable): Info =
if (isRecentlyPro()) Info(gracePeriod = true, billingData = null)
else Info(billingData = null, error = error)
private fun String.isIapSku(): Boolean =
CapodSku.PRO_SKUS.singleOrNull { it.id == this }?.type == Sku.Type.IAP
@@ -131,6 +167,13 @@ class UpgradeRepoGplay @Inject constructor(
// Grace is time-based, but billingData is equality-deduped state kept hot by a
// process-lifetime subscriber — without this deadline tick, a lapsed grace window would keep
// isPro=true until the next distinct billing emission or a process restart.
//
// Storage-failure resilient: the leading onStart emits immediately so a confirmed purchase in
// billingData never waits on the first DataStore read (combine can fire, and toUpgradeInfo()'s
// mapped-first branch surfaces the purchase without touching the cache). The trailing retryWhen
// catches a failure from EITHER the lastProStateAt.flow source OR graceWindowMs() and keeps the
// tick alive (emit + capped backoff), so a broken cache can't starve combine or terminate the
// stream.
private val graceDeadlineTick: Flow<Unit> = billingCache.lastProStateAt.flow
.flatMapLatest { lastProAt ->
flow {
@@ -144,6 +187,14 @@ class UpgradeRepoGplay @Inject constructor(
}
}
}
.onStart { emit(Unit) }
.retryWhen { error, attempt ->
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "graceDeadlineTick failed (attempt=$attempt): ${error.asLog()}" }
emit(Unit)
delay(retryDelayMs(attempt))
true
}
override val upgradeInfo: Flow<UpgradeRepo.Info> = combine(
billingDataRepo.billingData
@@ -152,26 +203,48 @@ class UpgradeRepoGplay @Inject constructor(
graceDeadlineTick,
) { data, _ -> data }
.map { data -> data.toUpgradeInfo() }
.catch { error ->
log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" }
val now = timeSource.currentTimeMillis()
if ((now - lastProStateAt) < graceWindowMs()) {
emit(Info(gracePeriod = true, billingData = null))
} else {
emit(Info(billingData = null, error = error))
}
.retryWhen { error, attempt ->
// Defensive backstop: toUpgradeInfo() now routes its cache access through the
// guarded+bounded isRecentlyPro() and so never throws for a failing/hung DataStore, and
// graceDeadlineTick keeps itself alive. This only fires on a genuinely unexpected
// upstream error — keep the flow alive (a terminal .catch would complete the shared flow
// and the process-lifetime subscriber would never let it recover) and emit a guarded
// fallback rather than terminating.
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "upgradeInfo mapping failed unexpectedly (attempt=$attempt): ${error.asLog()}" }
emit(graceOrError(error))
delay(retryDelayMs(attempt))
true
}
.shareIn(scope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
// True once we've ever confirmed a known Pro purchase on this install; drives the proactive
// restore banner. Local signal only — a fresh install or switched Google account starts false.
// Fail-soft: this is combined in the ViewModel OUTSIDE the hardened upgradeInfo, so a DataStore
// read failure here would otherwise terminate that combine and strand the screen even when
// upgradeInfo correctly reports Pro. On failure fall back to false and retry.
val wasEverPro: Flow<Boolean> = billingCache.lastProStateAt.flow
.map { it > 0 }
.retryWhen { error, attempt ->
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "wasEverPro read failed (attempt=$attempt): ${error.asLog()}" }
emit(false)
delay(retryDelayMs(attempt))
true
}
.distinctUntilChanged()
// Start of the current "fresh data can't confirm Pro" episode (0 = none open). Drives the
// two-stage grace UI: calm confirmation phase first, diagnostics once the episode has aged.
// Fail-soft for the same reason as wasEverPro: fall back to 0 (no open episode) and retry.
val proUnconfirmedSince: Flow<Long> = billingCache.proUnconfirmedAt.flow
.retryWhen { error, attempt ->
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "proUnconfirmedSince read failed (attempt=$attempt): ${error.asLog()}" }
emit(0L)
delay(retryDelayMs(attempt))
true
}
// True once any fresh billing observation arrived this process. The pre-reconciliation empty
// purchase state must not enable purchase actions — an owner on a fresh install would briefly
@@ -193,17 +266,27 @@ class UpgradeRepoGplay @Inject constructor(
return try {
val fresh = billingDataRepo.refresh()
// Returned data is fresh by definition — stamp it even if the flows dedupe the
// unchanged result and the init collector never sees a new emission.
recordProState(fresh)
// unchanged result and the init collector never sees a new emission. Best-effort: a
// failed cache write must not turn a successful Play restore into the grace/error path
// (the mapped info below is returned regardless).
try {
recordProState(fresh)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "restore: failed to record pro state: ${e.asLog()}" }
}
fresh.data.toUpgradeInfo()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Mirror the reactive flow's catch: a transient Play error while we were Pro recently
// keeps us Pro via the grace period; otherwise surface the error so the caller can show
// the proper "Play unavailable" message instead of a generic restore failure.
if ((timeSource.currentTimeMillis() - lastProStateAt) < graceWindowMs()) {
log(TAG, VERBOSE) { "Restore hit a Play error but we were Pro recently -> grace" }
// A transient Play error (or a cache read failure in the mapping) while we were Pro
// recently keeps us Pro via grace; otherwise surface the error so the caller can show the
// proper "Play unavailable" message instead of a generic restore failure. isRecentlyPro
// guards its own probe, so a second failure of the same broken cache resolves to "throw
// the original error" rather than escaping with the probe's exception.
if (isRecentlyPro()) {
log(TAG, VERBOSE) { "Restore hit an error but we were Pro recently -> grace" }
Info(gracePeriod = true, billingData = null)
} else {
throw e
@@ -215,19 +298,26 @@ class UpgradeRepoGplay @Inject constructor(
// Only relinquishes Pro if we haven't had it for a while (grace period). READ-ONLY: this also
// runs on replayed shared-flow data, so it must never stamp the grace cache — a refunded
// purchase could otherwise keep re-stamping its own grace window. See recordProState().
private fun BillingData?.toUpgradeInfo(): Info {
val now = timeSource.currentTimeMillis()
val proSku = this?.getProSku()
log(TAG) { "toUpgradeInfo(): now=$now, lastProStateAt=$lastProStateAt, data=$this" }
return when {
proSku != null -> Info(billingData = this, upgrades = this!!.getProSkus())
//
// Branch on MAPPED upgrades before any cache read: a confirmed known purchase is Pro even when
// local storage is unreadable (mapped-first return, no DataStore access), and a purchase list
// containing only products this app doesn't know maps to zero upgrades and correctly falls
// through to the grace check instead of masquerading as a confirmed purchase.
private suspend fun BillingData?.toUpgradeInfo(): Info {
val mapped = Info(billingData = this, upgrades = this?.getProSkus() ?: emptyList())
if (mapped.upgrades.isNotEmpty()) return mapped
(now - lastProStateAt) < graceWindowMs() -> {
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null)
}
else -> Info(billingData = this, upgrades = this?.getProSkus() ?: emptyList())
// No confirmed purchase (incl. the null pre-data placeholder the combine seeds): fall back to
// the grace window via the guarded+bounded probe. Routing through isRecentlyPro() — instead
// of reading the cache inline — means a failing/hung DataStore can NOT throw out of this
// mapping. If it could, the map's exception would tear down the flow and the retry would
// re-inject the null placeholder, looping forever and never processing a later confirmed
// purchase that arrives behind it in the sequential map.
return if (isRecentlyPro()) {
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null)
} else {
mapped
}
}
@@ -338,6 +428,16 @@ class UpgradeRepoGplay @Inject constructor(
internal fun preferredProSku(upgrades: Collection<PurchasedSku>): Sku? =
upgrades.firstOrNull { it.sku.type == Sku.Type.IAP }?.sku ?: upgrades.firstOrNull()?.sku
// Backoff for the local-DataStore-failure retries in upgradeInfo and graceDeadlineTick:
// 30s/60s/120s/240s, capped at 5min. Integer math on purpose — a Double-pow formula could
// overflow into a hot loop at extreme attempt counts. Pure and unit-tested.
internal fun retryDelayMs(attempt: Long): Long =
if (attempt >= 4) 300_000L else 30_000L shl attempt.toInt()
// Upper bound on a single grace-cache probe: a hung DataStore read resolves to "not
// recently pro" instead of wedging the sequential upgradeInfo mapping behind it.
private const val GRACE_PROBE_TIMEOUT_MS = 2_000L
private const val RESTORE_ON_OWNED_TIMEOUT_MS = 15_000L
val TAG: String = logTag("Upgrade", "Gplay", "Control")
@@ -74,16 +74,34 @@ data class BillingClientConnection(
purchasesGlobal,
queryCache,
) { global, cached ->
val combined = mutableSetOf<Purchase>()
// Dedup by purchaseToken, not Purchase identity: the query-cache snapshot and the listener
// overlay can hold the SAME purchase with a different ack-state — Play's immutable Purchase
// keeps reporting isAcknowledged=false until a fresh query supersedes it — so a Set/equals
// dedup (originalJson+signature differ) would keep BOTH. Insert the query-cache entries
// first, then let the listener overlay overwrite by token: reconcileListenerRecords() has
// already dropped same-token listener records after a non-raced query and deliberately KEEPS
// them when a purchase raced the query, so a surviving overlay entry is by construction the
// newer one — the overlay is not unconditionally fresher, it only wins when reconciliation
// left it in place.
val byToken = LinkedHashMap<String, Purchase>()
cached.iaps?.let { combined.addAll(it) }
cached.subs?.let { combined.addAll(it) }
fun keep(purchase: Purchase) {
if (purchase.purchaseToken.isBlank()) {
// Play supplies a non-empty token for PURCHASED purchases; a blank one is malformed
// and must not collapse every such record under the "" key.
log(TAG, WARN) { "Ignoring PURCHASED record with blank purchaseToken: $purchase" }
return
}
byToken[purchase.purchaseToken] = purchase
}
cached.iaps?.forEach { keep(it) }
cached.subs?.forEach { keep(it) }
global
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
.let { combined.addAll(it) }
.forEach { keep(it) }
combined.sortedByDescending { it.purchaseTime }
byToken.values.sortedByDescending { it.purchaseTime }
}
.setupCommonEventHandlers(TAG) { "purchases" }
@@ -77,6 +77,7 @@ object UpgradeScreenTags {
const val SUB_BUTTON = "upgrade.sub.button"
const val IAP_BUTTON = "upgrade.iap.button"
const val RESTORE_BUTTON = "upgrade.restore.button"
const val RETRY_BUTTON = "upgrade.retry.button"
const val RESTORE_BANNER = "upgrade.restore.banner"
const val OWNER_HERO = "upgrade.owner.hero"
const val OWNER_SUB_CARD = "upgrade.owner.subCard"
@@ -145,6 +146,7 @@ fun UpgradeScreenHost(
onSubscriptionTrial = { activity?.let { vm.onGoSubscriptionTrial(it) } },
onIap = { activity?.let { vm.onGoIap(it) } },
onRestore = { vm.restorePurchase() },
onRetry = { vm.retrySkuQuery() },
onManageSubscription = { vm.onManageSubscription() },
)
@@ -248,6 +250,7 @@ fun UpgradeScreen(
onIap: () -> Unit,
onRestore: () -> Unit,
onManageSubscription: () -> Unit,
onRetry: () -> Unit = {},
) {
Scaffold(
containerColor = MaterialTheme.colorScheme.surface,
@@ -307,6 +310,7 @@ fun UpgradeScreen(
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onRetry = onRetry,
)
state is UpgradeUiState.Loaded -> AcquisitionContent(
@@ -315,6 +319,7 @@ fun UpgradeScreen(
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onRetry = onRetry,
)
}
@@ -544,6 +549,7 @@ private fun GraceContent(
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit,
onRetry: () -> Unit = {},
) {
val grace = state.grace ?: return
@@ -609,6 +615,7 @@ private fun GraceContent(
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onRetry = onRetry,
showRestore = false,
)
}
@@ -623,6 +630,7 @@ private fun AcquisitionContent(
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit,
onRetry: () -> Unit = {},
) {
val benefits = listOf(
Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
@@ -713,6 +721,7 @@ private fun AcquisitionContent(
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onRetry = onRetry,
)
}
@@ -795,6 +804,7 @@ private fun PricingContent(
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit,
onRetry: () -> Unit = {},
showRestore: Boolean = true,
) {
// Subscription button (primary)
@@ -868,7 +878,9 @@ private fun PricingContent(
}
}
// If no details loaded at all, show a simple fallback upgrade button
// If no details loaded at all, show a simple fallback upgrade button (which re-queries the SKU
// at purchase time) plus a Retry that reloads the offers — a cold/slow Play store can otherwise
// leave price and subscription/trial selection unavailable for the whole screen visit.
if (!state.subAvailable && !state.iapAvailable) {
Button(
onClick = onIap,
@@ -892,6 +904,25 @@ private fun PricingContent(
color = MaterialTheme.colorScheme.onPrimary,
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onRetry,
// Disabled while a query is running so repeated taps can't thrash the query flow —
// owners/grace users keep this fallback visible price-independently.
enabled = !state.skuQueryInProgress,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.testTag(UpgradeScreenTags.RETRY_BUTTON),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = stringResource(R.string.general_retry_action),
style = MaterialTheme.typography.titleMedium,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -20,6 +20,9 @@ sealed interface UpgradeUiState {
val settled: Boolean = true,
val restoreInProgress: Boolean = false,
val verificationInProgress: Boolean = false,
// A SKU-detail query is still running. Owners/grace users render the fallback + Retry
// price-independently, so the Retry affordance disables itself while this is true.
val skuQueryInProgress: Boolean = false,
) : UpgradeUiState {
val subAvailable: Boolean get() = subscriptionAction != SubscriptionAction.UNAVAILABLE
val iapAvailable: Boolean get() = iapPrice != null
@@ -69,6 +72,7 @@ fun toLoadedState(
settled: Boolean,
restoreInProgress: Boolean,
verificationInProgress: Boolean,
skuQueryInProgress: Boolean = false,
): UpgradeUiState.Loaded {
val iapOffer = skus.iap?.details?.oneTimePurchaseOfferDetails
val subOffers = skus.sub?.details?.subscriptionOfferDetails
@@ -94,5 +98,6 @@ fun toLoadedState(
settled = settled,
restoreInProgress = restoreInProgress,
verificationInProgress = verificationInProgress,
skuQueryInProgress = skuQueryInProgress,
)
}
@@ -22,6 +22,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Duration
import javax.inject.Inject
@@ -86,17 +88,24 @@ class UpgradeViewModel @Inject constructor(
},
).stateIn(vmScope, SharingStarted.Eagerly, false)
// One aggregate SKU-detail query per ViewModel lifetime, both types concurrently. Failures
// Bumped by retrySkuQuery() to re-run the aggregate query after a cold/slow-Play failure left
// the offers unavailable — without it the Lazily-cached failure would brick offer selection for
// the whole ViewModel lifetime (only leaving and reopening the screen recovered).
private val retryTrigger = MutableStateFlow(0)
// One aggregate SKU-detail query per retry generation, both types concurrently. Failures
// resolve to null details — owners/grace render price-independently, acquisition users get
// the fallback purchase UI.
private val skuQueries = flow {
emit(SkuQueryState())
val result = coroutineScope {
val iap = async { querySkuDetailsSafe(CapodSku.Iap.PRO_UPGRADE) }
val sub = async { querySkuDetailsSafe(CapodSku.Sub.PRO_UPGRADE) }
SkuQueryState(done = true, iap = iap.await(), sub = sub.await())
private val skuQueries = retryTrigger.flatMapLatest {
flow {
emit(SkuQueryState())
val result = coroutineScope {
val iap = async { querySkuDetailsSafe(CapodSku.Iap.PRO_UPGRADE) }
val sub = async { querySkuDetailsSafe(CapodSku.Sub.PRO_UPGRADE) }
SkuQueryState(done = true, iap = iap.await(), sub = sub.await())
}
emit(result)
}
emit(result)
}.shareIn(vmScope, SharingStarted.Lazily, replay = 1)
private suspend fun querySkuDetailsSafe(sku: Sku): SkuDetails? = try {
@@ -110,6 +119,23 @@ class UpgradeViewModel @Inject constructor(
null
}
// Re-runs the SKU queries from the fallback "Retry" affordance. The button that calls this is
// disabled while a query is in flight (skuQueryInProgress), which is the actual thrash guard for
// owners/grace users who keep the fallback visible price-independently; a re-trigger that still
// slips through only cancels-and-restarts the flatMapLatest query (latest wins, each attempt is
// bounded by SKU_QUERY_TIMEOUT_MS), so it can't leak or wedge.
fun retrySkuQuery() {
log(TAG) { "retrySkuQuery()" }
retryTrigger.update { it + 1 }
}
// Manual restore OR the repo's invisible already-owned recovery — either one pauses the buy
// actions, so the two can't be raced against each other from the UI.
private val effectiveRestore: Flow<Boolean> = combine(
restoring,
upgradeRepo.autoRestoreBusy,
) { manual, auto -> manual || auto }
// Re-evaluates the grace presentation when the open episode crosses the diagnostics
// threshold — every other combined flow is distinct-until-changed and would never re-fire.
private val graceTick = upgradeRepo.proUnconfirmedSince
@@ -149,7 +175,7 @@ class UpgradeViewModel @Inject constructor(
billingState,
skuQueries,
settled,
restoring,
effectiveRestore,
purchaseBusy,
) { billing, skus, isSettled, isRestoring, isBusy ->
val info = billing.info
@@ -178,6 +204,9 @@ class UpgradeViewModel @Inject constructor(
settled = isSettled,
restoreInProgress = isRestoring,
verificationInProgress = isBusy,
// Owners/grace users keep the fallback + Retry visible while a query is still
// running; disable Retry then so repeated taps can't thrash the query flow.
skuQueryInProgress = !skus.done,
)
}
}.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), UpgradeUiState.Loading)
@@ -249,6 +278,14 @@ class UpgradeViewModel @Inject constructor(
// Single-flight for purchase actions: the guard is held from the tap until the Play sheet
// launch has resolved, so repeated taps can't stack verification queries or billing flows.
private suspend fun runExclusive(block: suspend () -> Unit) {
// Authoritative gate for the invisible already-owned recovery: button disabling is
// best-effort (recomposition lags a tap), so a subscribe/buy tap dispatched while the silent
// restore runs must be refused here, or it could buy the OTHER product on top of what the
// user already owns (a different-SKU double charge the ITEM_ALREADY_OWNED path won't catch).
if (upgradeRepo.autoRestoreBusy.value) {
log(TAG) { "Purchase action ignored, auto-restore in progress" }
return
}
if (restoring.value) {
log(TAG) { "Purchase action ignored, restore in progress" }
return
@@ -279,6 +316,11 @@ class UpgradeViewModel @Inject constructor(
}
fun restorePurchase() = launch {
// Don't overlap the invisible already-owned recovery — it is itself a restore.
if (upgradeRepo.autoRestoreBusy.value) {
log(TAG) { "restorePurchase() ignored, auto-restore in progress" }
return@launch
}
// Symmetric to runExclusive: a restore must not overlap an in-flight verification or
// billing launch either, or the user could end up with two result dialogs stacked.
if (purchaseBusy.value) {