From 8dcc4f8489c0e58b1731cb60520297a2922aa727 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Fri, 10 Jul 2026 22:28:30 +0200 Subject: [PATCH] fix(upgrade): Harden purchase restore and billing error handling --- .../common/upgrade/core/UpgradeRepoGplay.kt | 83 +++++++++------ .../core/client/BillingClientConnection.kt | 80 +++++++++++--- .../upgrade/core/data/BillingDataRepo.kt | 7 +- .../capod/upgrade/ui/UpgradeViewModel.kt | 38 +++++-- .../capod/common/error/ErrorEventHandler.kt | 17 ++- .../capod/common/error/LocalizedError.kt | 7 +- .../upgrade/core/UpgradeRepoGplayTest.kt | 68 ++++++++++++ .../client/BillingClientConnectionTest.kt | 97 +++++++++++++++++ .../capod/upgrade/ui/UpgradeViewModelTest.kt | 100 ++++++++++++++++++ 9 files changed, 430 insertions(+), 67 deletions(-) create mode 100644 app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt create mode 100644 app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt 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 f55b1432..fc1ed47c 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 @@ -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 = 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 { 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 = 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") } } 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 281ed329..2961c3c3 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 @@ -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>, ) { - private val queryCacheIaps = MutableStateFlow?>(null) - private val queryCacheSubs = MutableStateFlow?>(null) + private data class QueryCaches( + val iaps: Collection? = null, + val subs: Collection? = null, + ) + + private val queryCache = MutableStateFlow(QueryCaches()) val purchases: Flow> = combine( purchasesGlobal, - queryCacheIaps, - queryCacheSubs, - ) { global, iaps, subs -> + queryCache, + ) { global, cached -> val combined = mutableSetOf() - 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 = 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> = 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 { @@ -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>, + subs: Result>, + isAuthoritative: (Purchase) -> Boolean = { purchase -> + purchase.products.any { productId -> CapodSku.PRO_SKUS.any { it.id == productId } } + }, + ): Collection { + 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 + } + } } } 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 0184c967..091ec30e 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 @@ -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 } diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt index 63cace6c..8a356d71 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt @@ -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") } } diff --git a/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt b/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt index f0f4d39a..7b2c5d9f 100644 --- a/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt +++ b/app/src/main/java/eu/darken/capod/common/error/ErrorEventHandler.kt @@ -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)) diff --git a/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt b/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt index 66dbb752..e87ba4f9 100644 --- a/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt +++ b/app/src/main/java/eu/darken/capod/common/error/LocalizedError.kt @@ -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 } 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 f1c64de4..3cd98d95 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 @@ -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 { + repo.restorePurchaseNow() + } + + testScope.cancel() + } } diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt new file mode 100644 index 00000000..2f50dd19 --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt @@ -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 { + 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 { + BillingClientConnection.combinePurchaseResults( + iaps = Result.success(emptyList()), + subs = Result.failure(RuntimeException("SUBS query failed")), + ) + } + + shouldThrow { + BillingClientConnection.combinePurchaseResults( + iaps = Result.failure(RuntimeException("IAP query failed")), + subs = Result.success(emptyList()), + ) + } + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt new file mode 100644 index 00000000..c9558792 --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt @@ -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(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() + val errors = mutableListOf() + 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 + } +}