mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(upgrade): Re-check purchases when the app comes to the foreground
This commit is contained in:
+12
-1
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user