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).
|
||||
|
||||
+78
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user