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).
@@ -2,6 +2,9 @@ package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import eu.darken.capod.common.AppForegroundState
import eu.darken.capod.common.upgrade.core.client.BillingClientConnection
import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider
import eu.darken.capod.common.upgrade.core.client.BillingException
import eu.darken.capod.common.upgrade.core.client.BillingResultException
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException
@@ -10,10 +13,21 @@ import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo.Companion.tryMapUserFriendly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.runTest2
import java.time.Duration
class BillingDataRepoTest : BaseTest() {
@@ -85,4 +99,68 @@ class BillingDataRepoTest : BaseTest() {
val mapped = original.tryMapUserFriendly()
mapped shouldBe original
}
private class ForegroundRefreshHarness(testScope: TestScope) {
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow()
coEvery { refreshPurchases() } returns emptyList()
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(this@ForegroundRefreshHarness.clientConnection)
}
val foreground = MutableStateFlow(false)
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns foreground
}
val timeSource = TestTimeSource()
val repo = BillingDataRepo(provider, testScope, foregroundState, timeSource)
}
@Test
fun `coming to the foreground triggers a purchase refresh, throttled`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val harness = ForegroundRefreshHarness(testScope)
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 1) { harness.clientConnection.refreshPurchases() }
// Background/foreground again within the throttle window -> no additional query.
harness.foreground.value = false
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 1) { harness.clientConnection.refreshPurchases() }
testScope.cancel()
}
@Test
fun `foreground refresh runs again once the throttle window has passed`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val harness = ForegroundRefreshHarness(testScope)
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 1) { harness.clientConnection.refreshPurchases() }
harness.timeSource.advanceBy(Duration.ofMinutes(61))
harness.foreground.value = false
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 2) { harness.clientConnection.refreshPurchases() }
testScope.cancel()
}
@Test
fun `staying in the background never triggers a refresh`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val harness = ForegroundRefreshHarness(testScope)
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 0) { harness.clientConnection.refreshPurchases() }
testScope.cancel()
}
}