From 69febaac2897ce0851e694d672ffb8ba90d5816b Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Sat, 11 Jul 2026 12:00:00 +0200 Subject: [PATCH] feat(upgrade): Keep one-time Pro buyers Pro through longer Play outages --- .../capod/common/upgrade/core/BillingCache.kt | 7 ++ .../common/upgrade/core/UpgradeRepoGplay.kt | 50 ++++++++- .../upgrade/core/UpgradeRepoGplayTest.kt | 106 ++++++++++++++++++ 3 files changed, 157 insertions(+), 6 deletions(-) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt index 3f82628f..a84bc31d 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt @@ -26,4 +26,11 @@ class BillingCache @Inject constructor( "gplay.cache.lastProAt", 0L ) + + // SKU id of the last confirmed Pro purchase — determines which grace window applies. + // Empty for legacy installs that were Pro before this field existed. + val lastProStateSku = dataStore.createValue( + "gplay.cache.lastProSku", + "" + ) } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index db570913..890ea64f 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -41,6 +41,20 @@ class UpgradeRepoGplay @Inject constructor( get() = billingCache.lastProStateAt.valueBlocking set(value) { billingCache.lastProStateAt.valueBlocking = value } + private var lastProStateSku: String + get() = billingCache.lastProStateSku.valueBlocking + set(value) { billingCache.lastProStateSku.valueBlocking = value } + + private val anchorLock = Any() + + // Grace window depends on what was last owned: a permanent one-time purchase should almost + // never be dropped on a Play hiccup, so it gets a long window; a subscription legitimately + // lapses, so it keeps the short one (also used for unknown/legacy last SKUs). + private fun graceWindowMs(): Long = if (anchorIsIap()) GRACE_PERIOD_IAP_MS else GRACE_PERIOD_MS + + private fun anchorIsIap(): Boolean = + CapodSku.PRO_SKUS.singleOrNull { it.id == lastProStateSku }?.type == Sku.Type.IAP + override val upgradeInfo: Flow = billingDataRepo.billingData .map { it } .onStart { emit(null) } @@ -48,7 +62,7 @@ class UpgradeRepoGplay @Inject constructor( .catch { error -> log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" } val now = System.currentTimeMillis() - if ((now - lastProStateAt) < GRACE_PERIOD_MS) { + if ((now - lastProStateAt) < graceWindowMs()) { emit(Info(gracePeriod = true, billingData = null)) } else { emit(Info(billingData = null, error = error)) @@ -69,7 +83,7 @@ class UpgradeRepoGplay @Inject constructor( // Mirror the reactive flow's catch: a transient Play error while we were Pro recently // keeps us Pro via the grace period; otherwise surface the error so the caller can show // the proper "Play unavailable" message instead of a generic restore failure. - if ((System.currentTimeMillis() - lastProStateAt) < GRACE_PERIOD_MS) { + if ((System.currentTimeMillis() - lastProStateAt) < graceWindowMs()) { log(TAG, VERBOSE) { "Restore hit a Play error but we were Pro recently -> grace" } Info(gracePeriod = true, billingData = null) } else { @@ -86,11 +100,25 @@ class UpgradeRepoGplay @Inject constructor( log(TAG) { "toUpgradeInfo(): now=$now, lastProStateAt=$lastProStateAt, data=$this" } return when { proSku != null -> { - lastProStateAt = now - Info(billingData = this, upgrades = this!!.getProSkus()) + val upgrades = this!!.getProSkus() + // Record which Pro SKU anchors the grace window; the permanent IAP wins when both + // are owned. An IAP anchor is sticky: purchase data may lack the IAP because that + // query failed on a fresh connection, and a subscription seen in the meantime must + // not shrink the 30d window of an owner whose IAP was never disproven. Trade-off: + // a refunded IAP keeps the long window — consistent with the fail-open cache. + // SKU first, timestamp last — the timestamp is the gate, so a crash between the + // two writes stays conservative. Locked: mappings run concurrently from the hot + // reactive flow and direct restores, and the sticky check-then-write must not race. + synchronized(anchorLock) { + preferredProSku(upgrades) + ?.takeIf { it.type == Sku.Type.IAP || !anchorIsIap() } + ?.let { lastProStateSku = it.id } + lastProStateAt = now + } + Info(billingData = this, upgrades = upgrades) } - (now - lastProStateAt) < GRACE_PERIOD_MS -> { + (now - lastProStateAt) < graceWindowMs() -> { log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" } Info(gracePeriod = true, billingData = null) } @@ -162,8 +190,18 @@ class UpgradeRepoGplay @Inject constructor( private fun BillingData.getProSkus(): Collection = purchasedSkus .filter { it.sku in CapodSku.PRO_SKUS } - // Keep paying users Pro through transient empty/failed Play Billing responses. + // Keep paying users Pro through transient empty/failed Play Billing responses. A permanent + // one-time purchase should almost never be dropped on a hiccup, so it gets a long window; + // a subscription legitimately lapses, so it keeps the short one. GRACE_PERIOD_MS is the + // subscription/default window (also used when the last-owned SKU is unknown/legacy). val GRACE_PERIOD_MS = Duration.ofDays(7).toMillis() + val GRACE_PERIOD_IAP_MS = Duration.ofDays(30).toMillis() + + // The SKU whose grace window applies when several are owned: the permanent one-time + // purchase wins over a subscription (purchases are time-sorted, so a plain first() could + // pick a newer subscription and shrink the window). Null when nothing known is owned. + internal fun preferredProSku(upgrades: Collection): Sku? = + upgrades.firstOrNull { it.sku.type == Sku.Type.IAP }?.sku ?: upgrades.firstOrNull()?.sku private const val RESTORE_ON_OWNED_TIMEOUT_MS = 15_000L diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt index 2261f714..e98d0b3f 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt @@ -8,6 +8,7 @@ import eu.darken.capod.common.datastore.valueBlocking import eu.darken.capod.common.upgrade.core.client.ItemAlreadyOwnedBillingException import eu.darken.capod.common.upgrade.core.data.BillingData import eu.darken.capod.common.upgrade.core.data.BillingDataRepo +import eu.darken.capod.common.upgrade.core.data.PurchasedSku import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.longs.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldBeNull @@ -29,6 +30,7 @@ import org.junit.jupiter.api.io.TempDir import testhelpers.BaseTest import testhelpers.coroutine.runTest2 import java.io.File +import java.time.Duration class UpgradeRepoGplayTest : BaseTest() { @@ -52,6 +54,7 @@ class UpgradeRepoGplayTest : BaseTest() { ) billingCache = mockk { every { lastProStateAt } returns dataStore.createValue("gplay.cache.lastProAt", 0L) + every { lastProStateSku } returns dataStore.createValue("gplay.cache.lastProSku", "") } } @@ -278,6 +281,109 @@ class UpgradeRepoGplayTest : BaseTest() { testScope.cancel() } + @Test + fun `permanent IAP keeps grace well beyond the subscription window`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList()) + // 20 days ago: past the 7-day subscription window, but within the 30-day IAP window. + billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - Duration.ofDays(20).toMillis() + billingCache.lastProStateSku.valueBlocking = CapodSku.Iap.PRO_UPGRADE.id + val repo = createRepo(testScope) + + repo.restorePurchaseNow().isPro shouldBe true + + testScope.cancel() + } + + @Test + fun `subscription grace expires after the short window`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList()) + billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - Duration.ofDays(20).toMillis() + billingCache.lastProStateSku.valueBlocking = CapodSku.Sub.PRO_UPGRADE.id + val repo = createRepo(testScope) + + repo.restorePurchaseNow().isPro shouldBe false + + testScope.cancel() + } + + @Test + fun `legacy install without a recorded SKU gets the short window`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList()) + billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - Duration.ofDays(20).toMillis() + val repo = createRepo(testScope) + + repo.restorePurchaseNow().isPro shouldBe false + + testScope.cancel() + } + + @Test + fun `confirmed pro purchase records the SKU for the grace window`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.refresh() } returns BillingData( + purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)) + ) + val repo = createRepo(testScope) + + repo.restorePurchaseNow().isPro shouldBe true + billingCache.lastProStateSku.valueBlocking shouldBe CapodSku.Iap.PRO_UPGRADE.id + + testScope.cancel() + } + + @Test + fun `an IAP anchor is not downgraded by data that only shows a subscription`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + // Fresh connections start with empty query caches — a failed IAP query plus an owned + // subscription must not shrink the 30d window of an owner whose IAP was never disproven. + coEvery { billingDataRepo.refresh() } returns BillingData( + purchases = listOf(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)) + ) + billingCache.lastProStateSku.valueBlocking = CapodSku.Iap.PRO_UPGRADE.id + val repo = createRepo(testScope) + + repo.restorePurchaseNow().isPro shouldBe true + billingCache.lastProStateSku.valueBlocking shouldBe CapodSku.Iap.PRO_UPGRADE.id + + testScope.cancel() + } + + @Test + fun `a subscription anchor is upgraded when an IAP purchase is confirmed`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { billingDataRepo.refresh() } returns BillingData( + purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)) + ) + billingCache.lastProStateSku.valueBlocking = CapodSku.Sub.PRO_UPGRADE.id + val repo = createRepo(testScope) + + repo.restorePurchaseNow().isPro shouldBe true + billingCache.lastProStateSku.valueBlocking shouldBe CapodSku.Iap.PRO_UPGRADE.id + + testScope.cancel() + } + + @Test + fun `IAP grace window is longer than the subscription window`() { + (UpgradeRepoGplay.GRACE_PERIOD_IAP_MS > UpgradeRepoGplay.GRACE_PERIOD_MS) shouldBe true + UpgradeRepoGplay.GRACE_PERIOD_IAP_MS shouldBe Duration.ofDays(30).toMillis() + UpgradeRepoGplay.GRACE_PERIOD_MS shouldBe Duration.ofDays(7).toMillis() + } + + @Test + fun `preferredProSku prefers the permanent IAP when both are owned`() { + val iap = PurchasedSku(CapodSku.Iap.PRO_UPGRADE, mockk()) + val sub = PurchasedSku(CapodSku.Sub.PRO_UPGRADE, mockk()) + + UpgradeRepoGplay.preferredProSku(listOf(sub, iap))?.id shouldBe CapodSku.Iap.PRO_UPGRADE.id + UpgradeRepoGplay.preferredProSku(listOf(iap))?.id shouldBe CapodSku.Iap.PRO_UPGRADE.id + UpgradeRepoGplay.preferredProSku(listOf(sub))?.id shouldBe CapodSku.Sub.PRO_UPGRADE.id + UpgradeRepoGplay.preferredProSku(emptyList()) shouldBe null + } + @Test fun `already-owned buy attempt silently restores the purchase instead of erroring`() = runTest2 { val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))