mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -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.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).
|
||||||
|
|||||||
+78
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user