mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(upgrade): Harden purchase restore and billing error handling
This commit is contained in:
@@ -13,6 +13,7 @@ import eu.darken.capod.common.upgrade.core.data.BillingDataRepo
|
||||
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
|
||||
import eu.darken.capod.common.upgrade.core.data.Sku
|
||||
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -37,38 +39,13 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
set(value) { billingCache.lastProStateAt.valueBlocking = value }
|
||||
|
||||
override val upgradeInfo: Flow<UpgradeRepo.Info> = billingDataRepo.billingData
|
||||
.map { data ->
|
||||
val now = System.currentTimeMillis()
|
||||
val proSku = data.getProSku()
|
||||
log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, data=${data}" }
|
||||
when {
|
||||
proSku != null -> {
|
||||
lastProStateAt = now
|
||||
Info(billingData = data, upgrades = data.getProSkus())
|
||||
}
|
||||
|
||||
(now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L -> { // 7 days
|
||||
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
|
||||
Info(gracePeriod = true, billingData = null)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Info(billingData = data, upgrades = data.getProSkus())
|
||||
}
|
||||
}
|
||||
}
|
||||
.onStart {
|
||||
val now = System.currentTimeMillis()
|
||||
if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) {
|
||||
emit(Info(gracePeriod = true, billingData = null))
|
||||
} else {
|
||||
emit(Info(billingData = null))
|
||||
}
|
||||
}
|
||||
.map<BillingData, BillingData?> { it }
|
||||
.onStart { emit(null) }
|
||||
.map { data -> data.toUpgradeInfo() }
|
||||
.catch { error ->
|
||||
log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" }
|
||||
val now = System.currentTimeMillis()
|
||||
if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) {
|
||||
if ((now - lastProStateAt) < GRACE_PERIOD_MS) {
|
||||
emit(Info(gracePeriod = true, billingData = null))
|
||||
} else {
|
||||
emit(Info(billingData = null, error = error))
|
||||
@@ -76,6 +53,49 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
}
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
|
||||
|
||||
// Explicit "Restore purchase": query Play now and evaluate Pro from the returned data in the
|
||||
// same coroutine (real happens-before), so we never read a stale upgradeInfo replay. Billing
|
||||
// errors propagate so the caller can distinguish "not owned" from "Play unavailable".
|
||||
suspend fun restorePurchaseNow(): Info {
|
||||
log(TAG) { "restorePurchaseNow()" }
|
||||
return try {
|
||||
billingDataRepo.refresh().toUpgradeInfo()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// 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) {
|
||||
log(TAG, VERBOSE) { "Restore hit a Play error but we were Pro recently -> grace" }
|
||||
Info(gracePeriod = true, billingData = null)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared Pro/grace mapping used by both the reactive upgradeInfo flow and restorePurchaseNow().
|
||||
// Only relinquishes Pro if we haven't had it for a while (grace period).
|
||||
private fun BillingData?.toUpgradeInfo(): Info {
|
||||
val now = System.currentTimeMillis()
|
||||
val proSku = this?.getProSku()
|
||||
log(TAG) { "toUpgradeInfo(): now=$now, lastProStateAt=$lastProStateAt, data=$this" }
|
||||
return when {
|
||||
proSku != null -> {
|
||||
lastProStateAt = now
|
||||
Info(billingData = this, upgrades = this!!.getProSkus())
|
||||
}
|
||||
|
||||
(now - lastProStateAt) < GRACE_PERIOD_MS -> {
|
||||
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
|
||||
Info(gracePeriod = true, billingData = null)
|
||||
}
|
||||
|
||||
else -> Info(billingData = this, upgrades = this?.getProSkus() ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
data class Info(
|
||||
private val gracePeriod: Boolean = false,
|
||||
private val billingData: BillingData?,
|
||||
@@ -110,8 +130,6 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
offer: Sku.Subscription.Offer? = null,
|
||||
) = billingDataRepo.startBillingFlow(activity, sku, offer)
|
||||
|
||||
suspend fun refresh(): BillingData = billingDataRepo.refresh()
|
||||
|
||||
companion object {
|
||||
private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus
|
||||
.firstOrNull { it.sku in CapodSku.PRO_SKUS }
|
||||
@@ -119,6 +137,9 @@ 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.
|
||||
val GRACE_PERIOD_MS = Duration.ofDays(7).toMillis()
|
||||
|
||||
val TAG: String = logTag("Upgrade", "Gplay", "Control")
|
||||
}
|
||||
}
|
||||
|
||||
+68
-12
@@ -17,30 +17,36 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.upgrade.core.CapodSku
|
||||
import eu.darken.capod.common.upgrade.core.data.Sku
|
||||
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class BillingClientConnection(
|
||||
private val client: BillingClient,
|
||||
private val purchasesGlobal: Flow<Collection<Purchase>>,
|
||||
) {
|
||||
private val queryCacheIaps = MutableStateFlow<Collection<Purchase>?>(null)
|
||||
private val queryCacheSubs = MutableStateFlow<Collection<Purchase>?>(null)
|
||||
private data class QueryCaches(
|
||||
val iaps: Collection<Purchase>? = null,
|
||||
val subs: Collection<Purchase>? = null,
|
||||
)
|
||||
|
||||
private val queryCache = MutableStateFlow(QueryCaches())
|
||||
|
||||
val purchases: Flow<Collection<Purchase>> = combine(
|
||||
purchasesGlobal,
|
||||
queryCacheIaps,
|
||||
queryCacheSubs,
|
||||
) { global, iaps, subs ->
|
||||
queryCache,
|
||||
) { global, cached ->
|
||||
val combined = mutableSetOf<Purchase>()
|
||||
|
||||
iaps?.let { combined.addAll(it) }
|
||||
subs?.let { combined.addAll(it) }
|
||||
cached.iaps?.let { combined.addAll(it) }
|
||||
cached.subs?.let { combined.addAll(it) }
|
||||
|
||||
global
|
||||
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
|
||||
@@ -50,17 +56,48 @@ data class BillingClientConnection(
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "purchases" }
|
||||
|
||||
// Returns the freshly queried PURCHASED purchases so callers get a guaranteed happens-before
|
||||
// relation instead of racing the shared purchases/billingData replay caches after a refresh.
|
||||
// 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<Purchase> = coroutineScope {
|
||||
val iapsDeferred = async { queryPurchasesByType(BillingClient.ProductType.INAPP) }
|
||||
val subsDeferred = async { queryPurchasesByType(BillingClient.ProductType.SUBS) }
|
||||
val iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
|
||||
val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
|
||||
|
||||
val iaps = iapsDeferred.await()
|
||||
val subs = subsDeferred.await()
|
||||
log(TAG) { "refreshPurchases(): iaps=${iaps.getOrNull()}, subs=${subs.getOrNull()}" }
|
||||
|
||||
queryCacheIaps.value = iaps
|
||||
queryCacheSubs.value = subs
|
||||
// Evaluate before publishing: an inconclusive refresh (query failed and nothing
|
||||
// authoritative found) must not touch the caches at all, or a partially updated state
|
||||
// could surface synthetic ownership to the hot purchases flow and wrongly refresh the
|
||||
// grace anchor from stale data.
|
||||
val combined = combinePurchaseResults(iaps, subs)
|
||||
|
||||
(iaps + subs).sortedByDescending { it.purchaseTime }
|
||||
// Single atomic snapshot update; a failed type retains its previous value.
|
||||
queryCache.update { previous ->
|
||||
QueryCaches(
|
||||
iaps = iaps.getOrNull() ?: previous.iaps,
|
||||
subs = subs.getOrNull() ?: previous.subs,
|
||||
)
|
||||
}
|
||||
|
||||
combined
|
||||
}
|
||||
|
||||
// Never throws except on cancellation, so a single failing product-type query doesn't cancel
|
||||
// the sibling query (or the coroutineScope).
|
||||
private suspend fun queryPurchasedProducts(
|
||||
productType: String,
|
||||
): Result<Collection<Purchase>> = try {
|
||||
val purchased = queryPurchasesByType(productType)
|
||||
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
|
||||
Result.success(purchased)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
private suspend fun queryPurchasesByType(productType: String): Collection<Purchase> {
|
||||
@@ -162,5 +199,24 @@ data class BillingClientConnection(
|
||||
|
||||
companion object {
|
||||
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientConnection")
|
||||
|
||||
// Combines the two product-type query results: with a partial failure, only a known Pro
|
||||
// purchase found by the successful type may suppress the error — an unknown/legacy purchase
|
||||
// must not mask that the other type couldn't be verified. Without failures, everything
|
||||
// found is returned as-is. Pure and unit-tested.
|
||||
internal fun combinePurchaseResults(
|
||||
iaps: Result<Collection<Purchase>>,
|
||||
subs: Result<Collection<Purchase>>,
|
||||
isAuthoritative: (Purchase) -> Boolean = { purchase ->
|
||||
purchase.products.any { productId -> CapodSku.PRO_SKUS.any { it.id == productId } }
|
||||
},
|
||||
): Collection<Purchase> {
|
||||
val found = iaps.getOrNull().orEmpty() + subs.getOrNull().orEmpty()
|
||||
val error = iaps.exceptionOrNull() ?: subs.exceptionOrNull()
|
||||
return when {
|
||||
error == null || found.any(isAuthoritative) -> found.sortedByDescending { it.purchaseTime }
|
||||
else -> throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.common.upgrade.core.data
|
||||
|
||||
import android.app.Activity
|
||||
import com.android.billingclient.api.Purchase
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
@@ -46,10 +47,12 @@ class BillingDataRepo @Inject constructor(
|
||||
.onEach { (client, purchases) ->
|
||||
purchases
|
||||
.filter {
|
||||
val needsAck = !it.isAcknowledged
|
||||
// Only settled purchases can be acknowledged — acking a PENDING purchase
|
||||
// fails and would spin the retry loop below.
|
||||
val needsAck = !it.isAcknowledged && it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||
|
||||
if (needsAck) log(TAG, INFO) { "Needs ACK: $it" }
|
||||
else log(TAG) { "Already ACK'ed: $it" }
|
||||
else log(TAG) { "No ACK necessary: $it" }
|
||||
|
||||
needsAck
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.core.CapodSku
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
|
||||
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -177,23 +178,38 @@ class UpgradeViewModel @Inject constructor(
|
||||
|
||||
fun restorePurchase() = launch {
|
||||
log(TAG, INFO) { "restorePurchase()" }
|
||||
try {
|
||||
val data = upgradeRepo.refresh()
|
||||
log(TAG, INFO) { "Restore check: $data" }
|
||||
val info = upgradeRepo.upgradeInfo.first()
|
||||
if (info.isPro) {
|
||||
log(TAG) { "Pro purchase found" }
|
||||
} else {
|
||||
log(TAG) { "No pro purchase found" }
|
||||
|
||||
val restored = try {
|
||||
withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Play/billing error (e.g. service unavailable): surface the proper error dialog
|
||||
// instead of the generic "restore failed" toast, so the user can tell the cases apart.
|
||||
log(TAG, WARN) { "Restore purchase errored: ${e.asLog()}" }
|
||||
errorEvents.emitBlocking(e)
|
||||
return@launch
|
||||
}
|
||||
|
||||
when {
|
||||
restored == null -> {
|
||||
// Play never answered in time; the restore-failed message already suggests the
|
||||
// purchase may take a while to sync, which fits a timeout too.
|
||||
log(TAG, WARN) { "Restore purchase timed out" }
|
||||
events.tryEmit(UpgradeEvent.RestoreFailed)
|
||||
}
|
||||
|
||||
restored.isPro -> log(TAG, INFO) { "Restored purchase :))" }
|
||||
|
||||
else -> {
|
||||
log(TAG, WARN) { "No pro purchase found" }
|
||||
events.tryEmit(UpgradeEvent.RestoreFailed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Restore failed: $e" }
|
||||
errorEvents.emitBlocking(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val RESTORE_TIMEOUT_MS = 15_000L
|
||||
private val TAG = logTag("Upgrade", "VM")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@Composable
|
||||
@@ -31,17 +32,15 @@ private fun ComposeErrorDialog(
|
||||
throwable: Throwable,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Prefer the curated HasLocalizedError strings (e.g. billing errors) over raw exception
|
||||
// messages like Play Billing's internal debugMessage.
|
||||
val localizedError = remember(throwable, context) { throwable.localized(context) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(android.R.string.dialog_alert_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = throwable.localizedMessage
|
||||
?: throwable.message
|
||||
?: throwable::class.simpleName
|
||||
?: "Unknown error"
|
||||
)
|
||||
},
|
||||
title = { Text(text = localizedError.label) },
|
||||
text = { Text(text = localizedError.description) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(android.R.string.ok))
|
||||
|
||||
@@ -19,16 +19,19 @@ fun Throwable.localized(c: Context): LocalizedError = when {
|
||||
this is HasLocalizedError -> this.getLocalizedError(c)
|
||||
localizedMessage != null -> LocalizedError(
|
||||
throwable = this,
|
||||
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
|
||||
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
|
||||
description = localizedMessage ?: getStackTracePeek()
|
||||
)
|
||||
else -> LocalizedError(
|
||||
throwable = this,
|
||||
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
|
||||
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
|
||||
description = getStackTracePeek()
|
||||
)
|
||||
}
|
||||
|
||||
// Anonymous throwable classes have no simpleName — never crash while rendering an error.
|
||||
private fun Throwable.errorTypeName(): String = this::class.simpleName ?: "Error"
|
||||
|
||||
private fun Throwable.getStackTracePeek() = this.stackTraceToString()
|
||||
.lines()
|
||||
.filterIndexed { index, _ -> index > 1 }
|
||||
|
||||
@@ -6,9 +6,12 @@ import eu.darken.capod.common.datastore.createValue
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.upgrade.core.data.BillingData
|
||||
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.longs.shouldBeGreaterThan
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.cancel
|
||||
@@ -207,4 +210,69 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
val info = UpgradeRepoGplay.Info(billingData = null)
|
||||
info.type shouldBe eu.darken.capod.common.upgrade.UpgradeRepo.Type.GPLAY
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore returns pro when a purchase is found`() = 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
|
||||
// A confirmed pro purchase must refresh the grace timestamp.
|
||||
billingCache.lastProStateAt.valueBlocking shouldBeGreaterThan 0L
|
||||
|
||||
testScope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore keeps pro within grace when the query comes back empty`() = runTest2 {
|
||||
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
|
||||
coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList())
|
||||
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 1_000L
|
||||
val repo = createRepo(testScope)
|
||||
|
||||
repo.restorePurchaseNow().isPro shouldBe true
|
||||
|
||||
testScope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore is not pro when the query is empty and grace has expired`() = runTest2 {
|
||||
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
|
||||
coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList())
|
||||
billingCache.lastProStateAt.valueBlocking =
|
||||
System.currentTimeMillis() - UpgradeRepoGplay.GRACE_PERIOD_MS - 1_000L
|
||||
val repo = createRepo(testScope)
|
||||
|
||||
repo.restorePurchaseNow().isPro shouldBe false
|
||||
|
||||
testScope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore keeps pro within grace when the query errors`() = runTest2 {
|
||||
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
|
||||
coEvery { billingDataRepo.refresh() } throws RuntimeException("Play unavailable")
|
||||
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 1_000L
|
||||
val repo = createRepo(testScope)
|
||||
|
||||
repo.restorePurchaseNow().isPro shouldBe true
|
||||
|
||||
testScope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore rethrows the error when it happens outside grace`() = runTest2 {
|
||||
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
|
||||
coEvery { billingDataRepo.refresh() } throws RuntimeException("Play unavailable")
|
||||
val repo = createRepo(testScope)
|
||||
|
||||
shouldThrow<RuntimeException> {
|
||||
repo.restorePurchaseNow()
|
||||
}
|
||||
|
||||
testScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package eu.darken.capod.common.upgrade.core.client
|
||||
|
||||
import com.android.billingclient.api.Purchase
|
||||
import eu.darken.capod.common.upgrade.core.CapodSku
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class BillingClientConnectionTest : BaseTest() {
|
||||
|
||||
private fun mockPurchase(
|
||||
productId: String = CapodSku.Iap.PRO_UPGRADE.id,
|
||||
purchaseTime: Long = 1_000,
|
||||
): Purchase = mockk {
|
||||
every { products } returns listOf(productId)
|
||||
every { this@mockk.purchaseTime } returns purchaseTime
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combines both product types, newest first`() {
|
||||
val older = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, purchaseTime = 1_000)
|
||||
val newer = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, purchaseTime = 2_000)
|
||||
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.success(listOf(older)),
|
||||
subs = Result.success(listOf(newer)),
|
||||
) shouldBe listOf(newer, older)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single product-type failure does not mask a pro purchase found by the other`() {
|
||||
val owned = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
|
||||
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.success(listOf(owned)),
|
||||
subs = Result.failure(RuntimeException("SUBS query failed")),
|
||||
) shouldBe listOf(owned)
|
||||
|
||||
val ownedSub = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.failure(RuntimeException("IAP query failed")),
|
||||
subs = Result.success(listOf(ownedSub)),
|
||||
) shouldBe listOf(ownedSub)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown purchase does not suppress the other product-type's failure`() {
|
||||
// An unknown/legacy product is discarded by BillingData later — it must not hide that the
|
||||
// other product type couldn't be verified, or a restore would wrongly report "not owned".
|
||||
val unknown = mockPurchase("some.legacy.product")
|
||||
|
||||
shouldThrow<RuntimeException> {
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.success(listOf(unknown)),
|
||||
subs = Result.failure(RuntimeException("SUBS query failed")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown purchases are returned as-is when both queries succeed`() {
|
||||
val unknown = mockPurchase("some.legacy.product")
|
||||
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.success(listOf(unknown)),
|
||||
subs = Result.success(emptyList()),
|
||||
) shouldBe listOf(unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both product types empty returns empty`() {
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.success(emptyList()),
|
||||
subs = Result.success(emptyList()),
|
||||
) shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing found but a query failed rethrows the error`() {
|
||||
shouldThrow<RuntimeException> {
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.success(emptyList()),
|
||||
subs = Result.failure(RuntimeException("SUBS query failed")),
|
||||
)
|
||||
}
|
||||
|
||||
shouldThrow<RuntimeException> {
|
||||
BillingClientConnection.combinePurchaseResults(
|
||||
iaps = Result.failure(RuntimeException("IAP query failed")),
|
||||
subs = Result.success(emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package eu.darken.capod.upgrade.ui
|
||||
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.coroutine.TestDispatcherProvider
|
||||
import testhelpers.coroutine.runTest2
|
||||
|
||||
class UpgradeViewModelTest : BaseTest() {
|
||||
|
||||
private fun mockRepo(): UpgradeRepoGplay = mockk<UpgradeRepoGplay>(relaxed = true).apply {
|
||||
every { upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(billingData = null))
|
||||
}
|
||||
|
||||
private fun TestScope.createVm(repo: UpgradeRepoGplay) = UpgradeViewModel(
|
||||
dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher(testScheduler)),
|
||||
upgradeRepo = repo,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `restore with no purchase emits RestoreFailed`() = runTest2 {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(billingData = null)
|
||||
val vm = createVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.restorePurchase()
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore that finds a purchase stays silent`() = runTest2 {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(
|
||||
gracePeriod = true,
|
||||
billingData = null,
|
||||
)
|
||||
val vm = createVm(repo)
|
||||
|
||||
val events = mutableListOf<UpgradeViewModel.UpgradeEvent>()
|
||||
val errors = mutableListOf<Throwable>()
|
||||
val eventJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.events.collect { events.add(it) } }
|
||||
val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } }
|
||||
|
||||
vm.restorePurchase()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { repo.restorePurchaseNow() }
|
||||
events shouldBe emptyList()
|
||||
errors shouldBe emptyList()
|
||||
|
||||
eventJob.cancel()
|
||||
errorJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore that times out emits RestoreFailed`() = runTest2 {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.restorePurchaseNow() } coAnswers {
|
||||
delay(UpgradeViewModel.RESTORE_TIMEOUT_MS * 2)
|
||||
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
|
||||
}
|
||||
val vm = createVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.restorePurchase()
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore that errors forwards the error instead of RestoreFailed`() = runTest2 {
|
||||
val repo = mockRepo()
|
||||
val boom = IllegalStateException("Play unavailable")
|
||||
coEvery { repo.restorePurchaseNow() } throws boom
|
||||
val vm = createVm(repo)
|
||||
|
||||
val forwardedError = async { vm.errorEvents.first() }
|
||||
vm.restorePurchase()
|
||||
advanceUntilIdle()
|
||||
|
||||
forwardedError.await() shouldBe boom
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user