mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat(upgrade): Keep one-time Pro buyers Pro through longer Play outages
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user