feat(upgrade): Keep one-time Pro buyers Pro through longer Play outages

This commit is contained in:
Matthias Urhahn
2026-07-11 12:00:00 +02:00
committed by GitHub
parent 2f1cbe34e5
commit 69febaac28
3 changed files with 157 additions and 6 deletions
@@ -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",
""
)
}
@@ -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<UpgradeRepo.Info> = billingDataRepo.billingData
.map<BillingData, BillingData?> { 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<PurchasedSku> = 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<PurchasedSku>): Sku? =
upgrades.firstOrNull { it.sku.type == Sku.Type.IAP }?.sku ?: upgrades.firstOrNull()?.sku
private const val RESTORE_ON_OWNED_TIMEOUT_MS = 15_000L
@@ -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<Purchase>())
val sub = PurchasedSku(CapodSku.Sub.PRO_UPGRADE, mockk<Purchase>())
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))