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.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class BillingClientConnection( data class BillingClientConnection(
private val client: BillingClient, private val client: BillingClient,
@@ -41,6 +43,11 @@ data class BillingClientConnection(
private val queryCache = MutableStateFlow(QueryCaches()) 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( val purchases: Flow<Collection<Purchase>> = combine(
purchasesGlobal, purchasesGlobal,
queryCache, queryCache,
@@ -63,7 +70,11 @@ data class BillingClientConnection(
// Tolerant of a single product-type failure: a known Pro purchase found by either type is // 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 // authoritative, and an error only surfaces otherwise — so callers can tell "not owned" apart
// from "couldn't verify". // 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 iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) } val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
@@ -3,6 +3,8 @@ package eu.darken.capod.common.upgrade.core.data
import android.app.Activity import android.app.Activity
import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.Purchase 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.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.* import eu.darken.capod.common.debug.logging.Logging.Priority.*
@@ -16,6 +18,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -23,8 +26,14 @@ import javax.inject.Singleton
class BillingDataRepo @Inject constructor( class BillingDataRepo @Inject constructor(
billingClientConnectionProvider: BillingClientConnectionProvider, billingClientConnectionProvider: BillingClientConnectionProvider,
@AppScope private val scope: CoroutineScope, @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 private val connectionProvider = billingClientConnectionProvider.connection
.retryWhen { cause, attempt -> .retryWhen { cause, attempt ->
if (cause is CancellationException) return@retryWhen false if (cause is CancellationException) return@retryWhen false
@@ -91,6 +100,38 @@ class BillingDataRepo @Inject constructor(
true true
} }
.launchIn(scope) .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 { suspend fun refresh(): BillingData = try {
@@ -132,6 +173,9 @@ class BillingDataRepo @Inject constructor(
companion object { companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo") 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. // 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 // USER_CANCELED stays silent in the UI, ITEM_ALREADY_OWNED is auto-handled by
// UpgradeRepoGplay (restore instead of error). // 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.BillingClient
import com.android.billingclient.api.BillingResult 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.BillingException
import eu.darken.capod.common.upgrade.core.client.BillingResultException import eu.darken.capod.common.upgrade.core.client.BillingResultException
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException 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 eu.darken.capod.common.upgrade.core.data.BillingDataRepo.Companion.tryMapUserFriendly
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk 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 org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.runTest2
import java.time.Duration
class BillingDataRepoTest : BaseTest() { class BillingDataRepoTest : BaseTest() {
@@ -85,4 +99,68 @@ class BillingDataRepoTest : BaseTest() {
val mapped = original.tryMapUserFriendly() val mapped = original.tryMapUserFriendly()
mapped shouldBe original 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()
}
} }