diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt index f8d1bf8a..fe23f49b 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt @@ -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> = 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 = coroutineScope { + suspend fun refreshPurchases(): Collection = refreshLock.withLock { + refreshPurchasesLocked() + } + + private suspend fun refreshPurchasesLocked(): Collection = coroutineScope { val iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) } val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt index b9cdb72d..38f37704 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt @@ -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). diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt index fc3c5b55..6718f960 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt @@ -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 { + every { purchases } returns emptyFlow() + coEvery { refreshPurchases() } returns emptyList() + } + val provider = mockk { + every { connection } returns flowOf(this@ForegroundRefreshHarness.clientConnection) + } + val foreground = MutableStateFlow(false) + val foregroundState = mockk { + 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() + } }