mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -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.PurchasedSku
|
||||||
import eu.darken.capod.common.upgrade.core.data.Sku
|
import eu.darken.capod.common.upgrade.core.data.Sku
|
||||||
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.shareIn
|
import kotlinx.coroutines.flow.shareIn
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -37,38 +39,13 @@ class UpgradeRepoGplay @Inject constructor(
|
|||||||
set(value) { billingCache.lastProStateAt.valueBlocking = value }
|
set(value) { billingCache.lastProStateAt.valueBlocking = value }
|
||||||
|
|
||||||
override val upgradeInfo: Flow<UpgradeRepo.Info> = billingDataRepo.billingData
|
override val upgradeInfo: Flow<UpgradeRepo.Info> = billingDataRepo.billingData
|
||||||
.map { data ->
|
.map<BillingData, BillingData?> { it }
|
||||||
val now = System.currentTimeMillis()
|
.onStart { emit(null) }
|
||||||
val proSku = data.getProSku()
|
.map { data -> data.toUpgradeInfo() }
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { error ->
|
.catch { error ->
|
||||||
log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" }
|
log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" }
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) {
|
if ((now - lastProStateAt) < GRACE_PERIOD_MS) {
|
||||||
emit(Info(gracePeriod = true, billingData = null))
|
emit(Info(gracePeriod = true, billingData = null))
|
||||||
} else {
|
} else {
|
||||||
emit(Info(billingData = null, error = error))
|
emit(Info(billingData = null, error = error))
|
||||||
@@ -76,6 +53,49 @@ class UpgradeRepoGplay @Inject constructor(
|
|||||||
}
|
}
|
||||||
.shareIn(scope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
|
.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(
|
data class Info(
|
||||||
private val gracePeriod: Boolean = false,
|
private val gracePeriod: Boolean = false,
|
||||||
private val billingData: BillingData?,
|
private val billingData: BillingData?,
|
||||||
@@ -110,8 +130,6 @@ class UpgradeRepoGplay @Inject constructor(
|
|||||||
offer: Sku.Subscription.Offer? = null,
|
offer: Sku.Subscription.Offer? = null,
|
||||||
) = billingDataRepo.startBillingFlow(activity, sku, offer)
|
) = billingDataRepo.startBillingFlow(activity, sku, offer)
|
||||||
|
|
||||||
suspend fun refresh(): BillingData = billingDataRepo.refresh()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus
|
private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus
|
||||||
.firstOrNull { it.sku in CapodSku.PRO_SKUS }
|
.firstOrNull { it.sku in CapodSku.PRO_SKUS }
|
||||||
@@ -119,6 +137,9 @@ class UpgradeRepoGplay @Inject constructor(
|
|||||||
private fun BillingData.getProSkus(): Collection<PurchasedSku> = purchasedSkus
|
private fun BillingData.getProSkus(): Collection<PurchasedSku> = purchasedSkus
|
||||||
.filter { it.sku in CapodSku.PRO_SKUS }
|
.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")
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
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.Sku
|
||||||
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
data class BillingClientConnection(
|
data class BillingClientConnection(
|
||||||
private val client: BillingClient,
|
private val client: BillingClient,
|
||||||
private val purchasesGlobal: Flow<Collection<Purchase>>,
|
private val purchasesGlobal: Flow<Collection<Purchase>>,
|
||||||
) {
|
) {
|
||||||
private val queryCacheIaps = MutableStateFlow<Collection<Purchase>?>(null)
|
private data class QueryCaches(
|
||||||
private val queryCacheSubs = MutableStateFlow<Collection<Purchase>?>(null)
|
val iaps: Collection<Purchase>? = null,
|
||||||
|
val subs: Collection<Purchase>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val queryCache = MutableStateFlow(QueryCaches())
|
||||||
|
|
||||||
val purchases: Flow<Collection<Purchase>> = combine(
|
val purchases: Flow<Collection<Purchase>> = combine(
|
||||||
purchasesGlobal,
|
purchasesGlobal,
|
||||||
queryCacheIaps,
|
queryCache,
|
||||||
queryCacheSubs,
|
) { global, cached ->
|
||||||
) { global, iaps, subs ->
|
|
||||||
val combined = mutableSetOf<Purchase>()
|
val combined = mutableSetOf<Purchase>()
|
||||||
|
|
||||||
iaps?.let { combined.addAll(it) }
|
cached.iaps?.let { combined.addAll(it) }
|
||||||
subs?.let { combined.addAll(it) }
|
cached.subs?.let { combined.addAll(it) }
|
||||||
|
|
||||||
global
|
global
|
||||||
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
|
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
|
||||||
@@ -50,17 +56,48 @@ data class BillingClientConnection(
|
|||||||
}
|
}
|
||||||
.setupCommonEventHandlers(TAG) { "purchases" }
|
.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 {
|
suspend fun refreshPurchases(): Collection<Purchase> = coroutineScope {
|
||||||
val iapsDeferred = async { queryPurchasesByType(BillingClient.ProductType.INAPP) }
|
val iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
|
||||||
val subsDeferred = async { queryPurchasesByType(BillingClient.ProductType.SUBS) }
|
val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
|
||||||
|
|
||||||
val iaps = iapsDeferred.await()
|
val iaps = iapsDeferred.await()
|
||||||
val subs = subsDeferred.await()
|
val subs = subsDeferred.await()
|
||||||
|
log(TAG) { "refreshPurchases(): iaps=${iaps.getOrNull()}, subs=${subs.getOrNull()}" }
|
||||||
|
|
||||||
queryCacheIaps.value = iaps
|
// Evaluate before publishing: an inconclusive refresh (query failed and nothing
|
||||||
queryCacheSubs.value = subs
|
// 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> {
|
private suspend fun queryPurchasesByType(productType: String): Collection<Purchase> {
|
||||||
@@ -162,5 +199,24 @@ data class BillingClientConnection(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientConnection")
|
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
|
package eu.darken.capod.common.upgrade.core.data
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import com.android.billingclient.api.Purchase
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.debug.Bugs
|
import eu.darken.capod.common.debug.Bugs
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||||
@@ -46,10 +47,12 @@ class BillingDataRepo @Inject constructor(
|
|||||||
.onEach { (client, purchases) ->
|
.onEach { (client, purchases) ->
|
||||||
purchases
|
purchases
|
||||||
.filter {
|
.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" }
|
if (needsAck) log(TAG, INFO) { "Needs ACK: $it" }
|
||||||
else log(TAG) { "Already ACK'ed: $it" }
|
else log(TAG) { "No ACK necessary: $it" }
|
||||||
|
|
||||||
needsAck
|
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.CapodSku
|
||||||
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
|
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
|
||||||
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
import eu.darken.capod.common.upgrade.core.data.SkuDetails
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -177,23 +178,38 @@ class UpgradeViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun restorePurchase() = launch {
|
fun restorePurchase() = launch {
|
||||||
log(TAG, INFO) { "restorePurchase()" }
|
log(TAG, INFO) { "restorePurchase()" }
|
||||||
try {
|
|
||||||
val data = upgradeRepo.refresh()
|
val restored = try {
|
||||||
log(TAG, INFO) { "Restore check: $data" }
|
withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
|
||||||
val info = upgradeRepo.upgradeInfo.first()
|
} catch (e: CancellationException) {
|
||||||
if (info.isPro) {
|
throw e
|
||||||
log(TAG) { "Pro purchase found" }
|
} catch (e: Exception) {
|
||||||
} else {
|
// Play/billing error (e.g. service unavailable): surface the proper error dialog
|
||||||
log(TAG) { "No pro purchase found" }
|
// 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)
|
events.tryEmit(UpgradeEvent.RestoreFailed)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG) { "Restore failed: $e" }
|
|
||||||
errorEvents.emitBlocking(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
internal const val RESTORE_TIMEOUT_MS = 15_000L
|
||||||
private val TAG = logTag("Upgrade", "VM")
|
private val TAG = logTag("Upgrade", "VM")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -31,17 +32,15 @@ private fun ComposeErrorDialog(
|
|||||||
throwable: Throwable,
|
throwable: Throwable,
|
||||||
onDismiss: () -> Unit,
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(text = stringResource(android.R.string.dialog_alert_title)) },
|
title = { Text(text = localizedError.label) },
|
||||||
text = {
|
text = { Text(text = localizedError.description) },
|
||||||
Text(
|
|
||||||
text = throwable.localizedMessage
|
|
||||||
?: throwable.message
|
|
||||||
?: throwable::class.simpleName
|
|
||||||
?: "Unknown error"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text(text = stringResource(android.R.string.ok))
|
Text(text = stringResource(android.R.string.ok))
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ fun Throwable.localized(c: Context): LocalizedError = when {
|
|||||||
this is HasLocalizedError -> this.getLocalizedError(c)
|
this is HasLocalizedError -> this.getLocalizedError(c)
|
||||||
localizedMessage != null -> LocalizedError(
|
localizedMessage != null -> LocalizedError(
|
||||||
throwable = this,
|
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()
|
description = localizedMessage ?: getStackTracePeek()
|
||||||
)
|
)
|
||||||
else -> LocalizedError(
|
else -> LocalizedError(
|
||||||
throwable = this,
|
throwable = this,
|
||||||
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
|
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
|
||||||
description = getStackTracePeek()
|
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()
|
private fun Throwable.getStackTracePeek() = this.stackTraceToString()
|
||||||
.lines()
|
.lines()
|
||||||
.filterIndexed { index, _ -> index > 1 }
|
.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.datastore.valueBlocking
|
||||||
import eu.darken.capod.common.upgrade.core.data.BillingData
|
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.BillingDataRepo
|
||||||
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
|
import io.kotest.matchers.longs.shouldBeGreaterThan
|
||||||
import io.kotest.matchers.nulls.shouldBeNull
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
@@ -207,4 +210,69 @@ class UpgradeRepoGplayTest : BaseTest() {
|
|||||||
val info = UpgradeRepoGplay.Info(billingData = null)
|
val info = UpgradeRepoGplay.Info(billingData = null)
|
||||||
info.type shouldBe eu.darken.capod.common.upgrade.UpgradeRepo.Type.GPLAY
|
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