feat(upgrade): Re-check purchases when the app comes to the foreground

This commit is contained in:
Matthias Urhahn
2026-07-11 12:10:08 +02:00
committed by GitHub
parent b52c2cbe6f
commit 778d45170f
3 changed files with 134 additions and 1 deletions
@@ -29,6 +29,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class BillingClientConnection(
private val client: BillingClient,
@@ -41,6 +43,11 @@ data class BillingClientConnection(
private val queryCache = MutableStateFlow(QueryCaches())
// Serializes refreshes on this connection: the connect-time initial query, foreground
// refreshes, manual restores and already-owned recoveries may overlap, and an older query
// completing late must not overwrite the cache with stale purchases.
private val refreshLock = Mutex()
val purchases: Flow<Collection<Purchase>> = combine(
purchasesGlobal,
queryCache,
@@ -63,7 +70,11 @@ data class BillingClientConnection(
// Tolerant of a single product-type failure: a known Pro purchase found by either type is
// authoritative, and an error only surfaces otherwise — so callers can tell "not owned" apart
// from "couldn't verify".
suspend fun refreshPurchases(): Collection<Purchase> = coroutineScope {
suspend fun refreshPurchases(): Collection<Purchase> = refreshLock.withLock {
refreshPurchasesLocked()
}
private suspend fun refreshPurchasesLocked(): Collection<Purchase> = coroutineScope {
val iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
@@ -3,6 +3,8 @@ package eu.darken.capod.common.upgrade.core.data
import android.app.Activity
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.AppForegroundState
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
@@ -16,6 +18,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
@@ -23,8 +26,14 @@ import javax.inject.Singleton
class BillingDataRepo @Inject constructor(
billingClientConnectionProvider: BillingClientConnectionProvider,
@AppScope private val scope: CoroutineScope,
appForegroundState: AppForegroundState,
private val timeSource: TimeSource,
) {
// Monotonic (elapsedRealtime) so wall-clock corrections can't extend the throttle window.
// Null until the first attempt, so devices with less than an hour of uptime still refresh.
private var lastForegroundRefreshAt: Long? = null
private val connectionProvider = billingClientConnectionProvider.connection
.retryWhen { cause, attempt ->
if (cause is CancellationException) return@retryWhen false
@@ -91,6 +100,38 @@ class BillingDataRepo @Inject constructor(
true
}
.launchIn(scope)
// Play only pushes onPurchasesUpdated for purchases made in this session, and the
// connection (kept hot by App's AppScope subscriber) can live for the entire process
// lifetime — without a re-query, refunds, cross-device purchases or lapsed subscriptions
// are only noticed on app restart or manual restore. Google recommends re-querying
// purchases when the app comes to the foreground.
appForegroundState.isForeground
.filter { it }
.onEach {
val now = timeSource.elapsedRealtime()
val lastAt = lastForegroundRefreshAt
if (lastAt != null && now - lastAt < FOREGROUND_REFRESH_THROTTLE_MS) {
log(TAG, VERBOSE) { "Foreground purchase refresh throttled" }
return@onEach
}
// Advanced per attempt, not per success — a broken Play should not be hammered
// on every foreground transition.
lastForegroundRefreshAt = now
try {
// Bounded: an unavailable connection suspends refresh() indefinitely (60s
// retry loop) and would otherwise block all future foreground refreshes.
val result = withTimeoutOrNull(FOREGROUND_REFRESH_TIMEOUT_MS) { refresh() }
if (result != null) log(TAG) { "Foreground purchase refresh done" }
else log(TAG, WARN) { "Foreground purchase refresh timed out" }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Foreground purchase refresh failed: ${e.asLog()}" }
}
}
.setupCommonEventHandlers(TAG) { "foreground-refresh" }
.launchIn(scope)
}
suspend fun refresh(): BillingData = try {
@@ -132,6 +173,9 @@ class BillingDataRepo @Inject constructor(
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo")
private const val FOREGROUND_REFRESH_THROTTLE_MS = 60 * 60 * 1000L // 1h
private const val FOREGROUND_REFRESH_TIMEOUT_MS = 30_000L
// Expected environmental/user situations — user-facing handling only, no bug report.
// USER_CANCELED stays silent in the UI, ITEM_ALREADY_OWNED is auto-handled by
// UpgradeRepoGplay (restore instead of error).