General: See your Pro status and switch from subscription to one-time purchase (#638)

* feat(upgrade): Add Pro status view, grace UI and sub-to-IAP switch

* fix(upgrade): Pad restore purchase to a minimum visible duration

* ui(upgrade): Mention Play-website install fix in restore troubleshooting

* fix(upgrade): Stop re-acknowledging already-acked purchases

* ui(settings): Move upgrade status row into the Other category
This commit is contained in:
Matthias Urhahn
2026-07-22 15:40:57 +02:00
committed by GitHub
parent bfc0e1cfaa
commit 825892df74
30 changed files with 3134 additions and 537 deletions
+6
View File
@@ -198,6 +198,12 @@ dependencies {
"gplayImplementation"("com.android.billingclient:billing:8.0.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:8.0.0")
// Robolectric-backed Compose UI tests (run as regular unit tests via the vintage engine).
testImplementation(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
testImplementation("androidx.compose.ui:ui-test-junit4")
testImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("org.robolectric:robolectric:4.15.1")
"screenshotTestImplementation"(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
"screenshotTestImplementation"("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha13")
"screenshotTestImplementation"("androidx.compose.ui:ui-tooling")
@@ -13,7 +13,7 @@ import javax.inject.Inject
class UpgradeNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.Upgrade> { UpgradeScreenHost() }
entry<Nav.Main.Upgrade> { key -> UpgradeScreenHost(manage = key.manage) }
}
@Module
@@ -38,10 +38,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
@@ -60,14 +63,21 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
fun UpgradeScreenHost(
manage: Boolean = false,
vm: UpgradeViewModel = hiltViewModel(),
) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val snackbarHostState = remember { SnackbarHostState() }
val returnedEarlyMessage = stringResource(R.string.upgrade_foss_sponsor_returned_early)
val state by vm.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
@@ -90,11 +100,153 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
}
}
UpgradeScreen(
snackbarHostState = snackbarHostState,
onNavigateUp = { vm.navUp() },
onSponsor = { vm.sponsor() },
)
val current = state
when {
// DataStore hasn't answered yet — render nothing rather than flashing the sales route
// (with its armable unlock heuristic) at an existing supporter.
current == null -> Unit
manage && current.isPro -> {
// Existing supporter checking their status: no sales pitch, and the sponsor link here
// must not re-run the unlock heuristic (which would rewrite the supporter-since date).
SupporterStatusScreen(
upgradedAt = current.upgradedAt,
onNavigateUp = { vm.navUp() },
onSponsorPage = { vm.openSponsorPage() },
)
}
else -> UpgradeScreen(
snackbarHostState = snackbarHostState,
onNavigateUp = { vm.navUp() },
onSponsor = { vm.sponsor() },
)
}
}
@Composable
fun SupporterStatusScreen(
upgradedAt: java.time.Instant?,
onNavigateUp: () -> Unit,
onSponsorPage: () -> Unit,
) {
Scaffold(
containerColor = MaterialTheme.colorScheme.surface,
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
Box(contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(120.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
) {}
Image(
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = Modifier.size(80.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = buildAnnotatedString {
append("CAPod ")
withStyle(SpanStyle(color = colorResource(R.color.brand_secondary), fontWeight = FontWeight.Bold)) {
append("FOSS")
}
},
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(24.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier
.fillMaxWidth()
.testTag("upgrade.foss.supporterCard"),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Favorite,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = upgradedAt
?.let { instant ->
stringResource(
R.string.upgrade_foss_supporter_since,
remember(instant) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
.format(instant)
}
)
}
?: stringResource(R.string.upgrade_foss_supporter_thanks),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.upgrade_foss_supporter_thanks),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onSponsorPage,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(
imageVector = Icons.TwoTone.Favorite,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.upgrade_foss_sponsor_again_action),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier
.align(Alignment.TopStart)
.padding(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
}
}
}
private data class Benefit(val icon: ImageVector, val textRes: Int)
@@ -269,3 +421,13 @@ private fun UpgradeScreenPreview() = PreviewWrapper {
onSponsor = {},
)
}
@Preview2
@Composable
private fun SupporterStatusScreenPreview() = PreviewWrapper {
SupporterStatusScreen(
upgradedAt = java.time.Instant.parse("2025-11-02T12:00:00Z"),
onNavigateUp = {},
onSponsorPage = {},
)
}
@@ -9,6 +9,11 @@ import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.FossUpgrade
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
@@ -25,11 +30,28 @@ class UpgradeViewModel @Inject constructor(
val sponsorEvents = SingleEventFlow<SponsorEvent>()
data class State(
val isPro: Boolean = false,
val upgradedAt: Instant? = null,
)
// Null until DataStore answered: a defaulted isPro=false would flash the sales route (and its
// armable unlock heuristic) at an existing supporter opening their status.
val state: StateFlow<State?> = upgradeControlFoss.upgradeInfo
.map { info -> State(isPro = info.isPro, upgradedAt = info.upgradedAt) }
.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), null)
fun sponsor() {
savedStateHandle[KEY_SPONSOR_OPENED_AT] = SystemClock.elapsedRealtime()
webpageTool.open("https://github.com/sponsors/d4rken")
}
// Plain sponsor link for existing supporters: must NOT arm the unlock heuristic — re-running
// it would rewrite the "supporter since" date and navigate away from the status view.
fun openSponsorPage() {
webpageTool.open("https://github.com/sponsors/d4rken")
}
fun onResume() {
val openedAt = savedStateHandle.get<Long>(KEY_SPONSOR_OPENED_AT) ?: return
savedStateHandle.remove<Long>(KEY_SPONSOR_OPENED_AT)
@@ -4,33 +4,69 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.datastore.createValue
import javax.inject.Inject
import javax.inject.Singleton
private val Context.gplayDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings_gplay",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_gplay")) }
)
@Singleton
class BillingCache @Inject constructor(
@ApplicationContext private val context: Context,
class BillingCache internal constructor(
private val dataStore: DataStore<Preferences>,
) {
private val Context.dataStore by preferencesDataStore(
name = "settings_gplay",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_gplay")) }
)
@Inject constructor(@ApplicationContext context: Context) : this(context.gplayDataStore)
private val dataStore: DataStore<Preferences> get() = context.dataStore
val lastProStateAt = dataStore.createValue(
"gplay.cache.lastProAt",
0L
)
val lastProStateAt = dataStore.createValue(KEY_LAST_PRO_AT.name, 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",
""
)
val lastProStateSku = dataStore.createValue(KEY_LAST_PRO_SKU.name, "")
// Start of the current "fresh data can't confirm Pro" episode, 0 = no open episode. Drives
// the two-stage grace UI (calm confirmation phase first, diagnostics once the episode ages).
val proUnconfirmedAt = dataStore.createValue(KEY_PRO_UNCONFIRMED_AT.name, 0L)
// One transaction: a confirmed Pro purchase stamps the anchor (SKU only when the caller wants
// to move it) and atomically closes any unconfirmed episode. Observers and crash recovery
// must never see the anchor updated but the episode still open, or vice versa.
suspend fun stampLastProState(skuId: String?, at: Long) {
dataStore.edit { prefs ->
skuId?.let { prefs[KEY_LAST_PRO_SKU] = it }
prefs[KEY_LAST_PRO_AT] = at
prefs[KEY_PRO_UNCONFIRMED_AT] = 0L
}
}
// Starts the unconfirmed episode clock. Set-if-unset: follow-up failures must not push the
// diagnostics threshold out. An episode only exists relative to a previous confirmation, and
// a stored stamp from before that confirmation or from the future is corrupt state that gets
// repaired instead of trusted.
suspend fun recordProUnconfirmed(at: Long) {
dataStore.edit { prefs ->
val lastProAt = prefs[KEY_LAST_PRO_AT] ?: 0L
// Also rejects failures arriving moments after a confirmation: a confirmation and a
// conflicting empty snapshot within the same minute is emission reordering around a
// racing purchase event, not a real unconfirmed state.
if (lastProAt <= 0L || at - lastProAt < MIN_CONFIRMATION_AGE_MS) return@edit
val current = prefs[KEY_PRO_UNCONFIRMED_AT] ?: 0L
val corrupt = current != 0L && (current <= lastProAt || current > at)
if (current == 0L || corrupt) prefs[KEY_PRO_UNCONFIRMED_AT] = at
}
}
companion object {
private val KEY_LAST_PRO_AT = longPreferencesKey("gplay.cache.lastProAt")
private val KEY_LAST_PRO_SKU = stringPreferencesKey("gplay.cache.lastProSku")
private val KEY_PRO_UNCONFIRMED_AT = longPreferencesKey("gplay.cache.proUnconfirmedAt")
internal const val MIN_CONFIRMATION_AGE_MS = 60_000L
}
}
@@ -2,6 +2,8 @@ package eu.darken.capod.common.upgrade.core
import android.app.Activity
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -14,26 +16,34 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
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.FreshBillingData
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.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
@Singleton
@@ -41,17 +51,16 @@ class UpgradeRepoGplay @Inject constructor(
@AppScope private val scope: CoroutineScope,
private val billingDataRepo: BillingDataRepo,
private val billingCache: BillingCache,
private val timeSource: TimeSource,
) : UpgradeRepo {
private var lastProStateAt: Long
private val lastProStateAt: Long
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()
// Serializes the sticky check-then-write anchor logic: concurrent fresh observations (init
// collector, direct restores, failure events) must not interleave between reading the current
// anchor and stamping the new one.
private val proStateLock = Mutex()
init {
// Fresh-provenance grace stamping: freshBillingData carries every successful query result
@@ -59,9 +68,9 @@ class UpgradeRepoGplay @Inject constructor(
// unchanged steady-owner query still stamps, and stale listener data can't sneak in.
// The reactive upgradeInfo mapping deliberately writes nothing anymore.
billingDataRepo.freshBillingData
.onEach { data ->
.onEach { fresh ->
try {
recordProState(data)
recordProState(fresh)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -72,6 +81,25 @@ class UpgradeRepoGplay @Inject constructor(
.setupCommonEventHandlers(TAG) { "proStateRecorder" }
.launchIn(scope)
// Failed fresh-data attempts (query errors, timeouts) start the unconfirmed-episode clock.
// Most of these failures are swallowed by their pipelines (logged, retried later), so
// without this collector a sustained Play outage would never age the grace presentation
// from "confirming..." into its diagnostics stage.
billingDataRepo.refreshFailures
.onEach {
try {
proStateLock.withLock {
billingCache.recordProUnconfirmed(timeSource.currentTimeMillis())
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to record unconfirmed state: ${e.asLog()}" }
}
}
.setupCommonEventHandlers(TAG) { "unconfirmedRecorder" }
.launchIn(scope)
// Async variant of the launch-result ITEM_ALREADY_OWNED case: Play told us mid-flow that
// the user already owns it. Reconcile silently — Play shows its own UI for purchase-sheet
// failures, so no app-side dialog here.
@@ -94,18 +122,39 @@ class UpgradeRepoGplay @Inject constructor(
// 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 graceWindowMs(): Long =
if (billingCache.lastProStateSku.valueBlocking.isIapSku()) GRACE_PERIOD_IAP_MS else GRACE_PERIOD_MS
private fun anchorIsIap(): Boolean =
CapodSku.PRO_SKUS.singleOrNull { it.id == lastProStateSku }?.type == Sku.Type.IAP
private fun String.isIapSku(): Boolean =
CapodSku.PRO_SKUS.singleOrNull { it.id == this }?.type == Sku.Type.IAP
override val upgradeInfo: Flow<UpgradeRepo.Info> = billingDataRepo.billingData
.map<BillingData, BillingData?> { it }
.onStart { emit(null) }
// Grace is time-based, but billingData is equality-deduped state kept hot by a
// process-lifetime subscriber — without this deadline tick, a lapsed grace window would keep
// isPro=true until the next distinct billing emission or a process restart.
private val graceDeadlineTick: Flow<Unit> = billingCache.lastProStateAt.flow
.flatMapLatest { lastProAt ->
flow {
emit(Unit)
if (lastProAt > 0L) {
val remaining = lastProAt + graceWindowMs() - timeSource.currentTimeMillis()
if (remaining > 0) {
delay(remaining)
emit(Unit)
}
}
}
}
override val upgradeInfo: Flow<UpgradeRepo.Info> = combine(
billingDataRepo.billingData
.map<BillingData, BillingData?> { it }
.onStart { emit(null) },
graceDeadlineTick,
) { data, _ -> data }
.map { data -> data.toUpgradeInfo() }
.catch { error ->
log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" }
val now = System.currentTimeMillis()
val now = timeSource.currentTimeMillis()
if ((now - lastProStateAt) < graceWindowMs()) {
emit(Info(gracePeriod = true, billingData = null))
} else {
@@ -120,24 +169,40 @@ class UpgradeRepoGplay @Inject constructor(
.map { it > 0 }
.distinctUntilChanged()
// Start of the current "fresh data can't confirm Pro" episode (0 = none open). Drives the
// two-stage grace UI: calm confirmation phase first, diagnostics once the episode has aged.
val proUnconfirmedSince: Flow<Long> = billingCache.proUnconfirmedAt.flow
// True once any fresh billing observation arrived this process. The pre-reconciliation empty
// purchase state must not enable purchase actions — an owner on a fresh install would briefly
// look free and could buy the other product on top of what they already own.
val isSettled: Flow<Boolean> = billingDataRepo.freshBillingData
.map { true }
.onStart { emit(false) }
.distinctUntilChanged()
// Strict SUBS-only ownership check for the switch-to-IAP gate. Errors propagate — a
// subscriber whose renewal state can't be verified must not be allowed to double-buy.
suspend fun queryCurrentSubscriptions(): Collection<Purchase> = billingDataRepo.querySubscriptions()
// 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 {
val data = billingDataRepo.refresh()
val fresh = billingDataRepo.refresh()
// Returned data is fresh by definition — stamp it even if the flows dedupe the
// unchanged result and the init collector never sees a new emission.
recordProState(data)
data.toUpgradeInfo()
recordProState(fresh)
fresh.data.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) < graceWindowMs()) {
if ((timeSource.currentTimeMillis() - lastProStateAt) < graceWindowMs()) {
log(TAG, VERBOSE) { "Restore hit a Play error but we were Pro recently -> grace" }
Info(gracePeriod = true, billingData = null)
} else {
@@ -151,7 +216,7 @@ class UpgradeRepoGplay @Inject constructor(
// runs on replayed shared-flow data, so it must never stamp the grace cache — a refunded
// purchase could otherwise keep re-stamping its own grace window. See recordProState().
private fun BillingData?.toUpgradeInfo(): Info {
val now = System.currentTimeMillis()
val now = timeSource.currentTimeMillis()
val proSku = this?.getProSku()
log(TAG) { "toUpgradeInfo(): now=$now, lastProStateAt=$lastProStateAt, data=$this" }
return when {
@@ -166,23 +231,34 @@ class UpgradeRepoGplay @Inject constructor(
}
}
// Persists "we saw a known Pro purchase" for the grace machinery. Callers must only pass FRESH
// data (returned query results, or new emissions seen by the init collector) — never replayed
// flow data. The permanent IAP wins as anchor when both are owned, and an IAP anchor is sticky:
// purchase data may lack the IAP because that query failed on a fresh connection, and a
// Persists what fresh data told us about Pro ownership. Callers must only pass FRESH data
// (returned query results, or new emissions seen by the init collector) — never replayed flow
// data. A confirmed purchase stamps the anchor and atomically closes any unconfirmed episode;
// a full snapshot WITHOUT a Pro purchase conclusively failed to confirm and starts the episode
// clock; presence-only data without a purchase proves nothing either way. The permanent IAP
// wins as anchor when both are owned, and an IAP anchor is sticky: purchase data may lack the
// IAP because that query failed or was out of scope (SUBS-only verification), 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 before timestamp — the timestamp is the gate, so a crash between the
// two writes stays conservative. Locked: runs concurrently from the init collector and direct
// restores, and the sticky check-then-write must not race.
private fun recordProState(data: BillingData) {
val upgrades = data.getProSkus()
val preferred = preferredProSku(upgrades) ?: return
synchronized(anchorLock) {
preferred
.takeIf { it.type == Sku.Type.IAP || !anchorIsIap() }
?.let { lastProStateSku = it.id }
lastProStateAt = System.currentTimeMillis()
// fail-open cache). Locked: runs concurrently from the init collector, failure events and
// direct restores, and the sticky check-then-write must not race.
private suspend fun recordProState(fresh: FreshBillingData) {
val upgrades = fresh.data.getProSkus()
val preferred = preferredProSku(upgrades)
proStateLock.withLock {
when {
preferred != null -> {
val anchorIsIap = billingCache.lastProStateSku.value().isIapSku()
val anchorSku = preferred
.takeIf { it.type == Sku.Type.IAP || !anchorIsIap }
?.id
billingCache.stampLastProState(anchorSku, timeSource.currentTimeMillis())
}
fresh.isFullSnapshot -> {
billingCache.recordProUnconfirmed(timeSource.currentTimeMillis())
}
}
}
}
@@ -234,11 +310,11 @@ class UpgradeRepoGplay @Inject constructor(
log(TAG, WARN) { "Restore after already-owned failed: ${re.asLog()}" }
null
}
if (restored?.isPro != true) {
// Couldn't reconcile the entitlement (pending purchase, account mismatch, Play
// quirk) — fall back to the already-owned dialog with restore tips.
throw e
}
// Only the LAUNCHED entitlement showing up explains the already-owned launch failure —
// a grace-only isPro or a different owned SKU doesn't reconcile it. Fall back to the
// already-owned dialog with restore tips otherwise.
val reconciled = restored?.upgrades?.any { it.sku.id == sku.id } == true
if (!reconciled) throw e
}
}
@@ -35,9 +35,11 @@ import kotlinx.coroutines.sync.withLock
data class BillingClientConnection(
private val client: BillingClient,
private val purchasesGlobal: Flow<Collection<Purchase>>,
private val freshObservations: MutableSharedFlow<Collection<Purchase>>,
private val purchasesGlobal: MutableStateFlow<Collection<Purchase>>,
private val freshObservations: MutableSharedFlow<FreshPurchases>,
private val freshFailuresGlobal: MutableSharedFlow<Unit>,
private val purchaseFailuresGlobal: Flow<BillingResult>,
private val listenerGeneration: () -> Long,
) {
// Non-OK results from onPurchasesUpdated (e.g. async ITEM_ALREADY_OWNED after the Play sheet
@@ -47,7 +49,14 @@ data class BillingClientConnection(
// Every conclusive fresh look at PURCHASED purchases (successful queries and push payloads),
// regardless of whether it differs from the previous one — the combined `purchases` state is
// equality-deduped and can mix in stale listener data, so grace stamping must not use it.
val freshPurchases: Flow<Collection<Purchase>> = freshObservations
// Each observation carries provenance: only a full snapshot proves absence.
val freshPurchases: Flow<FreshPurchases> = freshObservations
// Failed attempts to get a fresh conclusive look (query errors, initial-query timeout).
// Consumed by UpgradeRepoGplay to start the unconfirmed-episode clock — without this, a
// sustained Play outage would never escalate the grace UI to its diagnostics stage.
val freshFailures: Flow<Unit> = freshFailuresGlobal
private data class QueryCaches(
val iaps: Collection<Purchase>? = null,
val subs: Collection<Purchase>? = null,
@@ -56,8 +65,9 @@ data class BillingClientConnection(
private val queryCache = MutableStateFlow(QueryCaches())
// Serializes refreshes on this connection: the connect-time initial query, foreground
// refreshes, manual restores and already-owned recoveries may overlap, and an older query
// completing late must not overwrite the cache with stale purchases.
// refreshes, manual restores, switch-gate verifications and already-owned recoveries may
// overlap, and an older query completing late must not overwrite the cache with stale
// purchases.
private val refreshLock = Mutex()
val purchases: Flow<Collection<Purchase>> = combine(
@@ -82,11 +92,13 @@ data class BillingClientConnection(
// 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> = refreshLock.withLock {
suspend fun refreshPurchases(): FreshPurchases = refreshLock.withLock {
refreshPurchasesLocked()
}
private suspend fun refreshPurchasesLocked(): Collection<Purchase> = coroutineScope {
private suspend fun refreshPurchasesLocked(): FreshPurchases = coroutineScope {
val generationBefore = listenerGeneration()
val iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
@@ -98,7 +110,14 @@ data class BillingClientConnection(
// 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)
val combined = try {
combinePurchaseResults(iaps, subs)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
freshFailuresGlobal.tryEmit(Unit)
throw e
}
// Single atomic snapshot update; a failed type retains its previous value.
queryCache.update { previous ->
@@ -107,12 +126,90 @@ data class BillingClientConnection(
subs = subs.getOrNull() ?: previous.subs,
)
}
val bothOk = iaps.isSuccess && subs.isSuccess
reconcileListenerRecords(
fresh = combined,
generationBefore = generationBefore,
// A conclusive both-type query proves absence for every product: listener records it
// didn't return are stale (refunded, expired) and must not linger until restart.
absenceProven = bothOk,
absenceScope = { true },
)
// Absence is only proven when both type queries succeeded AND no purchase event raced the
// queries: a racing event either flips the generation (downgrading this to presence-only),
// or its own fresh emission follows ours and immediately re-stamps the confirmation.
val isFullSnapshot = bothOk && listenerGeneration() == generationBefore
val fresh = FreshPurchases(combined, isFullSnapshot)
// A conclusive refresh is a fresh observation for the grace stamping, even when the result
// equals the previous one and the state flows dedupe it away.
freshObservations.tryEmit(combined)
freshObservations.tryEmit(fresh)
combined
fresh
}
// Strict SUBS-only verification query for the switch-to-IAP gate: errors propagate (the
// caller fails closed), and the result is committed to the caches so the ownership UI heals
// from stale renewal state (e.g. after the user cancelled the subscription in Play).
suspend fun querySubscriptions(): Collection<Purchase> = refreshLock.withLock {
val generationBefore = listenerGeneration()
val subs = try {
queryPurchasesByType(BillingClient.ProductType.SUBS)
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
freshFailuresGlobal.tryEmit(Unit)
throw e
}
queryCache.update { it.copy(subs = subs) }
reconcileListenerRecords(
fresh = subs,
generationBefore = generationBefore,
// A conclusive SUBS query proves absence for subscriptions only.
absenceProven = true,
absenceScope = { it.isKnownSub() },
)
// Presence-only provenance: a SUBS query proves nothing about the IAP.
freshObservations.tryEmit(FreshPurchases(subs, isFullSnapshot = false))
// Include listener-known subscription records the query doesn't cover (a purchase racing
// the query survives reconciliation) — over-blocking the switch is safe, missing a
// renewing sub is not.
val listenerSubs = purchasesGlobal.value.filter { listenerRecord ->
listenerRecord.purchaseState == Purchase.PurchaseState.PURCHASED &&
listenerRecord.isKnownSub() &&
subs.none { it.purchaseToken == listenerRecord.purchaseToken }
}
subs + listenerSubs
}
private fun Purchase.isKnownSub(): Boolean = products.any { it == CapodSku.Sub.PRO_UPGRADE.id }
// Reconciles the listener overlay against a fresh query result — but ONLY when no purchase
// event raced the query (generation unchanged, re-checked inside the CAS loop): a listener
// record published mid-query is NEWER than the query result and must survive, or a
// just-renewed subscription could be deleted by an older query and slip past the IAP gate.
// Without a race: fresh records supersede same-token listener records (a stale in-session
// isAutoRenewing=true must not coexist with newer query state forever), and when absence was
// proven, in-scope listener records the query didn't return are dropped entirely (refunded or
// expired purchases must not keep resurrecting until process restart).
private fun reconcileListenerRecords(
fresh: Collection<Purchase>,
generationBefore: Long,
absenceProven: Boolean,
absenceScope: (Purchase) -> Boolean,
) {
purchasesGlobal.update { current ->
if (listenerGeneration() != generationBefore) return@update current
current.filterNot { old ->
fresh.any { it.purchaseToken == old.purchaseToken } || (absenceProven && absenceScope(old))
}
}
}
// Never throws except on cancellation, so a single failing product-type query doesn't cancel
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
@@ -43,7 +44,7 @@ class BillingClientConnectionProvider @Inject constructor(
// subscribes (construction order race) — the latest fresh observation must not be lost.
// Failures stay replay=0: they can only originate from a purchase flow, which requires the
// consumer to already exist, and a consumed event must not be re-delivered.
val freshPurchaseObservations = MutableSharedFlow<Collection<Purchase>>(
val freshPurchaseObservations = MutableSharedFlow<FreshPurchases>(
replay = 1,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
@@ -52,6 +53,18 @@ class BillingClientConnectionProvider @Inject constructor(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
// replay=1: the connect-time initial query can fail or time out before UpgradeRepoGplay
// subscribes — that first failure must still start the unconfirmed-episode clock. A stale
// replayed failure is harmless (episode recording is set-if-unset and guarded).
val freshFailureEvents = MutableSharedFlow<Unit>(
replay = 1,
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
// Bumped on every successful onPurchasesUpdated BEFORE its data is published: a refresh
// compares the generation around its queries to detect a racing purchase event, which
// downgrades that refresh's snapshot from "proves absence" to "presence only".
val listenerGeneration = AtomicLong(0)
val client = newBuilder(context).apply {
enablePendingPurchases(
@@ -65,9 +78,15 @@ class BillingClientConnectionProvider @Inject constructor(
log(TAG) {
"onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases)"
}
listenerGeneration.incrementAndGet()
purchasePublisher.value = purchases.orEmpty()
freshPurchaseObservations.tryEmit(
purchases.orEmpty().filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
FreshPurchases(
purchases = purchases.orEmpty().filter { it.purchaseState == Purchase.PurchaseState.PURCHASED },
// Push payloads only carry this session's purchases — they prove
// presence, never absence.
isFullSnapshot = false,
)
)
} else {
log(TAG, WARN) {
@@ -94,7 +113,9 @@ class BillingClientConnectionProvider @Inject constructor(
client = client,
purchasesGlobal = purchasePublisher,
freshObservations = freshPurchaseObservations,
freshFailuresGlobal = freshFailureEvents,
purchaseFailuresGlobal = purchaseFailureEvents,
listenerGeneration = { listenerGeneration.get() },
)
trySendBlocking(connection)
@@ -107,8 +128,15 @@ class BillingClientConnectionProvider @Inject constructor(
val initial = withTimeoutOrNull(INITIAL_QUERY_TIMEOUT_MS) {
connection.refreshPurchases()
}
if (initial != null) log(TAG) { "Initial purchase query successful." }
else log(TAG, WARN) { "Initial purchase query timed out." }
if (initial != null) {
log(TAG) { "Initial purchase query successful." }
} else {
log(TAG, WARN) { "Initial purchase query timed out." }
// The timeout cancels the query before its own failure path
// runs — signal it here so the unconfirmed-episode clock can
// start during a sustained Play outage.
freshFailureEvents.tryEmit(Unit)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -0,0 +1,11 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.Purchase
// Provenance-tagged fresh purchase observation: a full snapshot (both product types queried
// conclusively, with no purchase event racing the queries) proves absence; anything else — push
// payloads, single-type queries, partial or raced refreshes — proves presence only.
data class FreshPurchases(
val purchases: Collection<Purchase>,
val isFullSnapshot: Boolean,
)
@@ -14,3 +14,11 @@ data class BillingData(
}
}
}
// Provenance-tagged fresh billing data: only a full snapshot (both product types queried
// conclusively, no racing purchase event) proves absence — anything else proves presence only.
// The grace machinery relies on this to never start an unconfirmed episode from partial data.
data class FreshBillingData(
val data: BillingData,
val isFullSnapshot: Boolean,
)
@@ -77,12 +77,31 @@ class BillingDataRepo @Inject constructor(
// Every fresh observation of PURCHASED purchases (successful queries and push payloads) —
// unlike billingData this is not equality-deduped state and never mixes in stale listener
// data, so it is the only valid source for grace stamping.
val freshBillingData: Flow<BillingData> = connectionProvider
// data, so it is the only valid source for grace stamping. Carries provenance: only full
// snapshots may prove absence.
val freshBillingData: Flow<FreshBillingData> = connectionProvider
.flatMapLatest { it.freshPurchases }
.map { BillingData(purchases = it) }
.map { FreshBillingData(data = BillingData(purchases = it.purchases), isFullSnapshot = it.isFullSnapshot) }
.setupCommonEventHandlers(TAG) { "freshBillingData" }
// Local failures the connection can't see itself (e.g. a foreground refresh timing out while
// the connection retry backoff is still waiting).
private val localRefreshFailures = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
// Failed attempts to get fresh conclusive purchase data (query errors, timeouts). Consumed by
// UpgradeRepoGplay to start the unconfirmed-episode clock during sustained Play outages.
val refreshFailures: Flow<Unit> = merge(
connectionProvider.flatMapLatest { it.freshFailures },
localRefreshFailures,
)
// Tokens successfully acknowledged this process: the immutable Purchase snapshots in the
// combined view keep claiming isAcknowledged=false until a fresh query supersedes them, and
// every re-emission would otherwise re-ack the same purchase — harmless to Play (repeat acks
// return OK) but a pointless extra IPC each time. Only recorded on SUCCESS, so a failed ack
// stays retryable. Single sequential collector, no locking needed.
private val ackedTokens = mutableSetOf<String>()
init {
connectionProvider
.flatMapLatest { client ->
@@ -93,7 +112,9 @@ class BillingDataRepo @Inject constructor(
.filter {
// 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
val needsAck = !it.isAcknowledged &&
it.purchaseState == Purchase.PurchaseState.PURCHASED &&
it.purchaseToken !in ackedTokens
if (needsAck) log(TAG, INFO) { "Needs ACK: $it" }
else log(TAG) { "No ACK necessary: $it" }
@@ -103,6 +124,7 @@ class BillingDataRepo @Inject constructor(
.forEach {
log(TAG, INFO) { "Acknowledging purchase: $it" }
client.acknowledgePurchase(it)
ackedTokens.add(it.purchaseToken)
}
}
.setupCommonEventHandlers(TAG) { "connection-acks" }
@@ -156,8 +178,14 @@ class BillingDataRepo @Inject constructor(
// Bounded: an unavailable connection suspends refresh() indefinitely (60s
// retry loop) and would otherwise block all future foreground refreshes.
val result = withTimeoutOrNull(FOREGROUND_REFRESH_TIMEOUT_MS) { refresh() }
if (result != null) log(TAG) { "Foreground purchase refresh done" }
else log(TAG, WARN) { "Foreground purchase refresh timed out" }
if (result != null) {
log(TAG) { "Foreground purchase refresh done" }
} else {
log(TAG, WARN) { "Foreground purchase refresh timed out" }
// The timeout cancels the query before its own failure path can signal —
// report it here so the unconfirmed-episode clock still starts.
localRefreshFailures.tryEmit(Unit)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -168,12 +196,22 @@ class BillingDataRepo @Inject constructor(
.launchIn(scope)
}
suspend fun refresh(): BillingData = try {
suspend fun refresh(): FreshBillingData = try {
connectionKicks.tryEmit(Unit)
val clientConnection = connectionProvider.first()
val purchases = clientConnection.refreshPurchases()
val fresh = clientConnection.refreshPurchases()
BillingData(purchases = purchases)
FreshBillingData(data = BillingData(purchases = fresh.purchases), isFullSnapshot = fresh.isFullSnapshot)
} catch (e: Exception) {
throw e.tryMapUserFriendly()
}
// Strict SUBS-only ownership check for the switch-to-IAP gate: errors propagate so callers
// can fail closed, and the fresh result is committed so stale renewal state heals.
suspend fun querySubscriptions(): Collection<Purchase> = try {
connectionKicks.tryEmit(Unit)
val clientConnection = connectionProvider.first()
clientConnection.querySubscriptions()
} catch (e: Exception) {
throw e.tryMapUserFriendly()
}
@@ -13,7 +13,7 @@ import javax.inject.Inject
class UpgradeNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.Upgrade> { UpgradeScreenHost() }
entry<Nav.Main.Upgrade> { key -> UpgradeScreenHost(manage = key.manage) }
}
@Module
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
package eu.darken.capod.upgrade.ui
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
sealed interface UpgradeUiState {
data object Loading : UpgradeUiState
data class Loaded(
val subscriptionAction: SubscriptionAction,
val subscriptionEnabled: Boolean,
val subscriptionPrice: String?,
val iapEnabled: Boolean,
val iapPrice: String?,
val ownership: Ownership = Ownership(),
val grace: GraceHint? = null,
val showRestoreBanner: Boolean = false,
val settled: Boolean = true,
val restoreInProgress: Boolean = false,
val verificationInProgress: Boolean = false,
) : UpgradeUiState {
val subAvailable: Boolean get() = subscriptionAction != SubscriptionAction.UNAVAILABLE
val iapAvailable: Boolean get() = iapPrice != null
}
}
// Pro but no owned purchase in the current data — the grace period is carrying the entitlement.
// Quiet at first (a Play hiccup usually resolves itself), diagnostics once the unconfirmed
// episode has aged past the threshold.
data class GraceHint(val showDiagnostics: Boolean)
data class Ownership(
val hasIap: Boolean = false,
val subscription: SubscriptionOwnership? = null,
) {
val ownsAnything: Boolean get() = hasIap || subscription != null
}
data class SubscriptionOwnership(val isAutoRenewing: Boolean)
enum class SubscriptionAction { TRIAL, STANDARD, UNAVAILABLE }
// Conservative: if ANY record for the sub SKU still claims auto-renew, treat it as renewing —
// that can only under-offer the switch to the one-time purchase, and the purchase gate
// re-verifies against a fresh SUBS query before any billing flow starts anyway.
fun UpgradeRepoGplay.Info.toOwnership() = Ownership(
hasIap = upgrades.any { it.sku.id == CapodSku.Iap.PRO_UPGRADE.id },
subscription = upgrades
.filter { it.sku.id == CapodSku.Sub.PRO_UPGRADE.id }
.takeIf { it.isNotEmpty() }
?.let { subs -> SubscriptionOwnership(isAutoRenewing = subs.any { it.purchase.isAutoRenewing }) },
)
// Aggregate result of the one-shot SKU detail queries. `done` distinguishes "queries still
// running" from "queries finished but found nothing" — owners render without waiting either way.
data class SkuQueryState(
val done: Boolean = false,
val iap: SkuDetails? = null,
val sub: SkuDetails? = null,
)
fun toLoadedState(
skus: SkuQueryState,
ownership: Ownership,
grace: GraceHint?,
showRestoreBanner: Boolean,
settled: Boolean,
restoreInProgress: Boolean,
verificationInProgress: Boolean,
): UpgradeUiState.Loaded {
val iapOffer = skus.iap?.details?.oneTimePurchaseOfferDetails
val subOffers = skus.sub?.details?.subscriptionOfferDetails
val baseOffer = subOffers?.firstOrNull { CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(it) }
val trialOffer = subOffers?.firstOrNull { CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(it) }
return UpgradeUiState.Loaded(
subscriptionAction = when {
trialOffer != null -> SubscriptionAction.TRIAL
baseOffer != null -> SubscriptionAction.STANDARD
else -> SubscriptionAction.UNAVAILABLE
},
// `settled` gates all purchase actions until the first billing reconciliation (or its
// bounded fallback): the initially-empty purchase state must not let an owner on a fresh
// install buy the other product before their existing purchase has been seen.
subscriptionEnabled = settled && ownership.subscription == null && !restoreInProgress && !verificationInProgress,
subscriptionPrice = baseOffer?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice,
iapEnabled = settled && !ownership.hasIap && !restoreInProgress && !verificationInProgress,
iapPrice = iapOffer?.formattedPrice,
ownership = ownership,
grace = grace,
showRestoreBanner = showRestoreBanner,
settled = settled,
restoreInProgress = restoreInProgress,
verificationInProgress = verificationInProgress,
)
}
@@ -2,6 +2,9 @@ package eu.darken.capod.upgrade.ui
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -16,168 +19,252 @@ import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException
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.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Duration
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepoGplay,
private val webpageTool: WebpageTool,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
sealed interface UpgradeEvent {
data object RestoreFailed : UpgradeEvent
data object RestoreSucceeded : UpgradeEvent
data object SubscriptionStillRenewing : UpgradeEvent
data object SubscriptionCheckFailed : UpgradeEvent
}
sealed interface BillingEvent {
data object LaunchIap : BillingEvent
data object LaunchSubscription : BillingEvent
data object LaunchSubscriptionTrial : BillingEvent
}
data class Pricing(
val iap: SkuDetails? = null,
val sub: SkuDetails? = null,
val hasIap: Boolean = false,
val hasSub: Boolean = false,
val subPrice: String? = null,
val iapPrice: String? = null,
val hasTrialOffer: Boolean = false,
) {
val subAvailable: Boolean get() = sub != null || subPrice != null
val iapAvailable: Boolean get() = iap != null || iapPrice != null
}
// Restore affordances shown alongside the pricing state. Kept as a separate reactive state so
// the one-shot pricing query isn't re-run whenever upgradeInfo or the restore flag changes.
data class RestoreState(
val showRestoreBanner: Boolean = false,
val restoreInProgress: Boolean = false,
)
val events = SingleEventFlow<UpgradeEvent>()
val billingEvents = SingleEventFlow<BillingEvent>()
private val restoring = MutableStateFlow(false)
val restoreState: StateFlow<RestoreState> = combine(
upgradeRepo.wasEverPro,
// Single-flight guard for ALL purchase actions, held from tap until the Play sheet launch
// resolved — the disabled buttons are best-effort (recomposition lags), this is authoritative.
private val purchaseBusy = MutableStateFlow(false)
// Route binding: null until the host reports whether this is the manage route. The auto-close
// collector waits for it, so a manage visit can never race a premature navUp().
private val manageRoute = MutableStateFlow<Boolean?>(null)
fun initialize(manage: Boolean) {
if (manageRoute.value == null) {
log(TAG) { "initialize(manage=$manage)" }
manageRoute.value = manage
}
}
// Purchase actions stay disabled until the first billing reconciliation of this process (or a
// bounded fallback so a Play outage can't brick the buttons): the initially-empty purchase
// state must not let an owner on a fresh install double-buy.
private val settled: StateFlow<Boolean> = merge(
upgradeRepo.isSettled.filter { it },
flow {
delay(SETTLE_FALLBACK_MS)
emit(true)
},
).stateIn(vmScope, SharingStarted.Eagerly, false)
// One aggregate SKU-detail query per ViewModel lifetime, both types concurrently. Failures
// resolve to null details — owners/grace render price-independently, acquisition users get
// the fallback purchase UI.
private val skuQueries = flow {
emit(SkuQueryState())
val result = coroutineScope {
val iap = async { querySkuDetailsSafe(CapodSku.Iap.PRO_UPGRADE) }
val sub = async { querySkuDetailsSafe(CapodSku.Sub.PRO_UPGRADE) }
SkuQueryState(done = true, iap = iap.await(), sub = sub.await())
}
emit(result)
}.shareIn(vmScope, SharingStarted.Lazily, replay = 1)
private suspend fun querySkuDetailsSafe(sku: Sku): SkuDetails? = try {
withTimeoutOrNull(SKU_QUERY_TIMEOUT_MS) {
upgradeRepo.querySkus(sku).firstOrNull()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to query SKU ${sku.id}: ${e.asLog()}" }
null
}
// Re-evaluates the grace presentation when the open episode crosses the diagnostics
// threshold — every other combined flow is distinct-until-changed and would never re-fire.
private val graceTick = upgradeRepo.proUnconfirmedSince
.flatMapLatest { stamp ->
flow {
emit(Unit)
if (stamp > 0L) {
val remaining = stamp + GRACE_DIAGNOSTICS_AFTER_MS - timeSource.currentTimeMillis()
if (remaining > 0) {
delay(remaining)
emit(Unit)
}
}
}
}
private data class BillingState(
val info: UpgradeRepoGplay.Info?,
val wasEverPro: Boolean,
val proUnconfirmedSince: Long,
)
private val billingState = combine(
upgradeRepo.upgradeInfo,
upgradeRepo.wasEverPro,
upgradeRepo.proUnconfirmedSince,
graceTick,
) { info, wasEverPro, unconfirmedSince, _ ->
BillingState(
info = info as? UpgradeRepoGplay.Info,
wasEverPro = wasEverPro,
proUnconfirmedSince = unconfirmedSince,
)
}
val state: StateFlow<UpgradeUiState> = combine(
billingState,
skuQueries,
settled,
restoring,
) { wasEverPro, info, isRestoring ->
RestoreState(
// Hidden while a grace period or an actual purchase keeps the user Pro.
showRestoreBanner = wasEverPro && !info.isPro,
restoreInProgress = isRestoring,
)
}.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), RestoreState())
val state: StateFlow<Pricing?> = flow {
val iapDetails = try {
withTimeoutOrNull(5_000L) {
upgradeRepo.querySkus(CapodSku.Iap.PRO_UPGRADE).firstOrNull()
}
} catch (e: Exception) {
log(TAG, WARN) { "Failed to query IAP SKU: ${e.asLog()}" }
null
}
val subDetails = try {
withTimeoutOrNull(5_000L) {
upgradeRepo.querySkus(CapodSku.Sub.PRO_UPGRADE).firstOrNull()
}
} catch (e: Exception) {
log(TAG, WARN) { "Failed to query Sub SKU: ${e.asLog()}" }
null
}
val info = try {
withTimeoutOrNull(5_000L) {
upgradeRepo.upgradeInfo.first() as? UpgradeRepoGplay.Info
}
} catch (e: Exception) {
log(TAG, WARN) { "Failed to get upgrade info: ${e.asLog()}" }
null
}
val subProductDetails = subDetails?.details
val baseOffer = subProductDetails?.subscriptionOfferDetails?.firstOrNull { offerDetails ->
CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails)
}
val hasTrialOffer = subProductDetails?.subscriptionOfferDetails?.any { offerDetails ->
CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails)
} == true
emit(
Pricing(
iap = iapDetails,
sub = subDetails,
hasIap = info?.hasIap == true,
hasSub = info?.hasSub == true,
subPrice = baseOffer?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice,
iapPrice = iapDetails?.details?.oneTimePurchaseOfferDetails?.formattedPrice,
hasTrialOffer = hasTrialOffer,
purchaseBusy,
) { billing, skus, isSettled, isRestoring, isBusy ->
val info = billing.info
val ownership = info?.toOwnership() ?: Ownership()
val grace = if (info?.isPro == true && !ownership.ownsAnything) {
GraceHint(
showDiagnostics = billing.proUnconfirmedSince > 0L &&
timeSource.currentTimeMillis() - billing.proUnconfirmedSince >= GRACE_DIAGNOSTICS_AFTER_MS
)
)
}.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), null)
} else {
null
}
// Owners and grace users render price-independently: their status view must not degrade
// to a spinner (or an error) just because the SKU pricing queries failed or are slow.
val priceIndependent = ownership.ownsAnything || grace != null
if (!priceIndependent && !skus.done) {
UpgradeUiState.Loading
} else {
toLoadedState(
skus = skus,
ownership = ownership,
grace = grace,
// Hidden while a grace period or an actual purchase keeps the user Pro.
showRestoreBanner = billing.wasEverPro && info?.isPro != true,
settled = isSettled,
restoreInProgress = isRestoring,
verificationInProgress = isBusy,
)
}
}.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), UpgradeUiState.Loading)
init {
upgradeRepo.upgradeInfo
.onEach { info ->
if (info.isPro) {
log(TAG) { "User is now pro, navigating back" }
navUp()
}
// Sales route: close once the user is Pro (purchase completed, or they were Pro all
// along). Manage route: never auto-close — it exists to LOOK at the status.
manageRoute
.filterNotNull()
.flatMapLatest { manage ->
if (manage) emptyFlow() else upgradeRepo.upgradeInfo
}
.filter { it.isPro }
.take(1)
.onEach {
log(TAG) { "User is pro on the sales route, navigating back" }
navUp()
}
.launchIn(vmScope)
}
fun onGoIap() {
fun onGoIap(activity: Activity) {
log(TAG, INFO) { "onGoIap()" }
billingEvents.tryEmit(BillingEvent.LaunchIap)
}
launch {
runExclusive {
// ALWAYS verify against a fresh SUBS-only query, not just for known subscribers:
// the replayed ownership state can be stale or still empty right after process
// start, and a renewing subscriber must never double-buy. Fails closed.
val subscriptions = try {
withTimeoutOrNull(VERIFY_TIMEOUT_MS) { upgradeRepo.queryCurrentSubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Subscription verification failed: ${e.asLog()}" }
errorEvents.emitBlocking(e)
return@runExclusive
}
when {
subscriptions == null -> {
log(TAG, WARN) { "Subscription verification timed out" }
events.tryEmit(UpgradeEvent.SubscriptionCheckFailed)
}
fun onGoSubscription() {
log(TAG, INFO) { "onGoSubscription()" }
billingEvents.tryEmit(BillingEvent.LaunchSubscription)
}
subscriptions.any { it.isAutoRenewing } -> {
log(TAG, INFO) { "Subscription still set to renew -> blocking IAP purchase" }
events.tryEmit(UpgradeEvent.SubscriptionStillRenewing)
}
fun onGoSubscriptionTrial() {
log(TAG, INFO) { "onGoSubscriptionTrial()" }
billingEvents.tryEmit(BillingEvent.LaunchSubscriptionTrial)
}
fun launchBillingIap(activity: Activity) {
log(TAG, INFO) { "launchBillingIap()" }
launchBillingFlow(activity, CapodSku.Iap.PRO_UPGRADE, null)
}
fun launchBillingSubscription(activity: Activity) {
log(TAG, INFO) { "launchBillingSubscription()" }
launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.BASE_OFFER)
}
fun launchBillingSubscriptionTrial(activity: Activity) {
log(TAG, INFO) { "launchBillingSubscriptionTrial()" }
launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER)
}
private fun launchBillingFlow(activity: Activity, sku: Sku, offer: Sku.Subscription.Offer?) = launch {
// The disabled buy buttons are best-effort (recomposition lags the flag) — this is the
// authoritative guard against starting a purchase while a restore is still running.
if (restoring.value) {
log(TAG) { "launchBillingFlow(${sku.id}) ignored, restore in progress" }
return@launch
else -> launchBillingFlow(activity, CapodSku.Iap.PRO_UPGRADE, null)
}
}
}
}
fun onGoSubscription(activity: Activity) {
log(TAG, INFO) { "onGoSubscription()" }
launch {
runExclusive { launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.BASE_OFFER) }
}
}
fun onGoSubscriptionTrial(activity: Activity) {
log(TAG, INFO) { "onGoSubscriptionTrial()" }
launch {
runExclusive { launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER) }
}
}
// Single-flight for purchase actions: the guard is held from the tap until the Play sheet
// launch has resolved, so repeated taps can't stack verification queries or billing flows.
private suspend fun runExclusive(block: suspend () -> Unit) {
if (restoring.value) {
log(TAG) { "Purchase action ignored, restore in progress" }
return
}
if (!purchaseBusy.compareAndSet(expect = false, update = true)) {
log(TAG) { "Purchase action ignored, another one is in flight" }
return
}
try {
block()
} finally {
purchaseBusy.value = false
}
}
private suspend fun launchBillingFlow(activity: Activity, sku: Sku, offer: Sku.Subscription.Offer?) {
try {
upgradeRepo.launchBillingFlow(activity, sku, offer)
} catch (e: CancellationException) {
@@ -192,6 +279,12 @@ class UpgradeViewModel @Inject constructor(
}
fun restorePurchase() = launch {
// Symmetric to runExclusive: a restore must not overlap an in-flight verification or
// billing launch either, or the user could end up with two result dialogs stacked.
if (purchaseBusy.value) {
log(TAG) { "restorePurchase() ignored, purchase action in flight" }
return@launch
}
// Single-flight: repeated taps while a restore is running (worst case bounded by
// RESTORE_TIMEOUT_MS) must not stack concurrent restores and duplicate result messages.
if (!restoring.compareAndSet(expect = false, update = true)) {
@@ -201,7 +294,15 @@ class UpgradeViewModel @Inject constructor(
log(TAG, INFO) { "restorePurchase()" }
try {
val restored = withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
// Pad the round-trip to a minimum visible duration, CONCURRENTLY with the real query
// (a pad, not an add-on): warm caches can answer instantly, and a spinner that flashes
// for a single frame leaves the user unsure whether anything actually happened.
val restored = coroutineScope {
val minVisible = async { delay(RESTORE_MIN_VISIBLE_MS) }
val result = withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
minVisible.await()
result
}
when {
restored == null -> {
// Play never answered in time; the restore-failed message already suggests the
@@ -210,7 +311,12 @@ class UpgradeViewModel @Inject constructor(
events.tryEmit(UpgradeEvent.RestoreFailed)
}
restored.isPro -> log(TAG, INFO) { "Restored purchase :))" }
// An actual returned purchase is required — a grace-only isPro means Play still
// couldn't confirm anything, which is not a successful restore.
restored.upgrades.isNotEmpty() -> {
log(TAG, INFO) { "Restored purchase :))" }
events.tryEmit(UpgradeEvent.RestoreSucceeded)
}
else -> {
log(TAG, WARN) { "No pro purchase found" }
@@ -230,8 +336,42 @@ class UpgradeViewModel @Inject constructor(
}
}
fun onManageSubscription() {
log(TAG, INFO) { "onManageSubscription()" }
webpageTool.open(PLAY_SUBSCRIPTION_URL)
}
fun onResume() {
// Returning from Play (e.g. after cancelling renewal on the Manage page) must reflect the
// new renewal state promptly — the global foreground refresh is throttled to once an
// hour, which would leave the switch offer locked long after the user cancelled.
val current = state.value
val hasSub = (current as? UpgradeUiState.Loaded)?.ownership?.subscription != null
if (!hasSub) return
launch {
try {
withTimeoutOrNull(VERIFY_TIMEOUT_MS) { upgradeRepo.queryCurrentSubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Resume subscription refresh failed: ${e.asLog()}" }
}
}
}
companion object {
internal const val RESTORE_TIMEOUT_MS = 15_000L
// Long enough that the user believes a round-trip to Play happened, short enough not
// to drag.
internal const val RESTORE_MIN_VISIBLE_MS = 1_500L
internal const val VERIFY_TIMEOUT_MS = 10_000L
// The first SKU query after a Play sign-in has been observed to take >8s.
internal const val SKU_QUERY_TIMEOUT_MS = 15_000L
internal const val SETTLE_FALLBACK_MS = 10_000L
internal val GRACE_DIAGNOSTICS_AFTER_MS = Duration.ofHours(24).toMillis()
internal val PLAY_SUBSCRIPTION_URL =
"https://play.google.com/store/account/subscriptions" +
"?sku=${CapodSku.Sub.PRO_UPGRADE.id}&package=${BuildConfigWrap.APPLICATION_ID}"
private val TAG = logTag("Upgrade", "VM")
}
}
@@ -20,7 +20,7 @@ object Nav {
data object TroubleShooter : Main
@Serializable
data object Upgrade : Main
data class Upgrade(val manage: Boolean = false) : Main
@Serializable
data class DeviceSettings(val profileId: String) : Main
@@ -151,7 +151,7 @@ class MainActivity : Activity2() {
if (intent?.getBooleanExtra(EXTRA_NAVIGATE_TO_UPGRADE, false) == true) {
intent.removeExtra(EXTRA_NAVIGATE_TO_UPGRADE)
if (generalSettings.isOnboardingDone.valueBlocking) {
navCtrl.goTo(Nav.Main.Upgrade)
navCtrl.goTo(Nav.Main.Upgrade())
}
}
}
@@ -283,7 +283,7 @@ class DeviceSettingsViewModel @Inject constructor(
if (upgradeRepo.isPro()) {
sendInternal(command)
} else {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
}
}
@@ -311,7 +311,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAllowOffOption(enabled: Boolean) = launch {
if (!upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
if (enabled) {
@@ -334,7 +334,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun setSleepDetection(enabled: Boolean) = launch {
log(TAG, INFO) { "setSleepDetection($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
sendInternal(AapCommand.SetSleepDetection(enabled))
@@ -386,7 +386,7 @@ class DeviceSettingsViewModel @Inject constructor(
) = launch {
// Disabling never requires pro; enabling does.
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
updateProfileNow(transform)
@@ -402,7 +402,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAutoPlay(enabled: Boolean) = launch {
log(TAG, INFO) { "setAutoPlay($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
updateProfileNow { it.copy(autoPlay = enabled) }
@@ -412,7 +412,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAutoPause(enabled: Boolean) = launch {
log(TAG, INFO) { "setAutoPause($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
updateProfileNow { it.copy(autoPause = enabled) }
@@ -422,7 +422,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun setStartMusicOnWear(enabled: Boolean) = launch {
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
updateProfileNow { it.copy(startMusicOnWear = enabled) }
@@ -491,7 +491,7 @@ class DeviceSettingsViewModel @Inject constructor(
// The action picker is only shown while Conversation Awareness is already enabled (the pod
// emits no speaking frames otherwise), so no need to auto-enable it here.
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
updateProfileNow { it.copy(conversationAction = action) }
@@ -535,7 +535,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun launchUpgrade() {
log(TAG, INFO) { "launchUpgrade()" }
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
}
fun openIssueTracker() {
@@ -334,7 +334,7 @@ class OverviewViewModel @Inject constructor(
fun onUpgrade() {
log(TAG, INFO) { "onUpgrade()" }
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
}
fun toggleUnmatchedDevices() {
@@ -144,7 +144,7 @@ class PressControlsViewModel @Inject constructor(
// 2. Free-clear allowance — always allow clearing to None.
// 3. Otherwise Pro is required.
if (action != StemAction.None && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
return@launch
}
// 4. Mutate + cross-side effect.
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.automirrored.twotone.MenuBook
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Settings
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material.icons.twotone.SupportAgent
import androidx.compose.material.icons.twotone.Translate
import androidx.compose.material3.Icon
@@ -33,6 +34,7 @@ import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.common.upgrade.UpgradeRepo
@Composable
fun SettingsScreenHost(vm: SettingsViewModel = hiltViewModel()) {
@@ -46,6 +48,7 @@ fun SettingsScreenHost(vm: SettingsViewModel = hiltViewModel()) {
onNavigateUp = { vm.navUp() },
onGeneralSettings = { vm.navTo(Nav.Settings.General) },
onDeviceManager = { vm.navTo(Nav.Main.DeviceManager) },
onUpgradeStatus = { vm.navTo(Nav.Main.Upgrade(manage = true)) },
onSupport = { vm.navTo(Nav.Settings.Support) },
onWiki = { vm.openUrl("https://github.com/d4rken-org/capod/wiki") },
onChangelog = { vm.openUrl("https://capod.darken.eu/changelog") },
@@ -63,6 +66,7 @@ fun SettingsScreen(
onNavigateUp: () -> Unit,
onGeneralSettings: () -> Unit,
onDeviceManager: () -> Unit,
onUpgradeStatus: () -> Unit,
onSupport: () -> Unit,
onWiki: () -> Unit,
onChangelog: () -> Unit,
@@ -121,6 +125,23 @@ fun SettingsScreen(
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_other_label))
}
item {
// Always visible: owners need a way to check their Pro/supporter status, and
// non-owners get another path to the upgrade screen.
val isFoss = state.upgradeType == UpgradeRepo.Type.FOSS
SettingsBaseItem(
title = stringResource(
if (isFoss) R.string.settings_upgrade_status_foss_label
else R.string.settings_upgrade_status_gplay_label
),
subtitle = stringResource(
if (isFoss) R.string.settings_upgrade_status_foss_description
else R.string.settings_upgrade_status_gplay_description
),
icon = Icons.TwoTone.Stars,
onClick = onUpgradeStatus,
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_support_label),
@@ -181,6 +202,7 @@ private fun SettingsScreenPreview() = PreviewWrapper {
onNavigateUp = {},
onGeneralSettings = {},
onDeviceManager = {},
onUpgradeStatus = {},
onSupport = {},
onWiki = {},
onChangelog = {},
@@ -20,10 +20,17 @@ class SettingsViewModel @Inject constructor(
data class State(
val isPro: Boolean,
val sponsorUrl: String?,
val upgradeType: UpgradeRepo.Type = UpgradeRepo.Type.GPLAY,
)
val state = upgradeRepo.upgradeInfo
.map { State(isPro = it.isPro, sponsorUrl = upgradeRepo.getSponsorUrl()) }
.map {
State(
isPro = it.isPro,
sponsorUrl = upgradeRepo.getSponsorUrl(),
upgradeType = it.type,
)
}
.asLiveState()
fun openUrl(url: String) {
@@ -107,7 +107,7 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) {
generalSettings.themeMode.valueBlocking = mode
} else {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
}
}
@@ -116,7 +116,7 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) {
generalSettings.themeStyle.valueBlocking = style
} else {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
}
}
@@ -125,13 +125,13 @@ class GeneralSettingsViewModel @Inject constructor(
if (isPro.first()) {
generalSettings.themeColor.valueBlocking = color
} else {
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
}
}
fun launchUpgrade() {
log(TAG, INFO) { "launchUpgrade()" }
navTo(Nav.Main.Upgrade)
navTo(Nav.Main.Upgrade())
}
companion object {
+31
View File
@@ -44,8 +44,35 @@
<string name="upgrade_screen_restore_troubleshooting_msg">If you\'ve recently purchased, it may take a moment for Google Play to sync.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Try again in a few minutes if your purchase doesn\'t appear.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Make sure you\'re signed in with the same Google account used for the purchase.</string>
<string name="upgrade_screen_restore_webinstall_hint">Multiple accounts can confuse Google Play. Installing the app through the Google Play website forces the association with a specific account.</string>
<string name="upgrade_screen_restore_banner_title">Already bought Pro?</string>
<string name="upgrade_screen_restore_banner_body">It looks like you upgraded to Pro on this device before. Restore your purchase to unlock it again.</string>
<string name="upgrade_screen_restore_success_message">Purchase restored.</string>
<string name="upgrade_screen_restore_status_title">Status looks wrong?</string>
<string name="upgrade_screen_restore_status_body">If you own an upgrade that isn\'t shown here, restore your purchase.</string>
<string name="upgrade_screen_owned_hero_title">You have CAPod Pro!</string>
<string name="upgrade_screen_owned_hero_sub_body">Unlocked by your subscription. Thank you for supporting CAPod!</string>
<string name="upgrade_screen_owned_hero_iap_body">Unlocked by your one-time purchase. Thank you for supporting CAPod!</string>
<string name="upgrade_screen_owned_sub_title">Yearly subscription</string>
<string name="upgrade_screen_owned_sub_renewing_body">Your subscription is active and set to renew. Billing is managed by Google Play.</string>
<string name="upgrade_screen_owned_sub_not_renewing_body">Your subscription is active, but not set to renew. Pro stays available until the current period ends.</string>
<string name="upgrade_screen_owned_iap_title">One-time purchase</string>
<string name="upgrade_screen_owned_iap_body">Yours forever. No renewals, no expiry.</string>
<string name="upgrade_screen_owned_both_renewing_warning">You own the one-time purchase, but your subscription is still set to renew. Cancel it in Google Play to avoid paying twice.</string>
<string name="upgrade_screen_manage_subscription_action">Manage subscription</string>
<string name="upgrade_screen_iap_offer_title">Switch to the one-time purchase</string>
<string name="upgrade_screen_owned_iap_locked_note">Available once your subscription is no longer set to renew. Cancel it in Google Play to switch.</string>
<string name="upgrade_screen_owned_iap_purchase_note">This is a separate purchase — it does not cancel or refund your subscription. Use the same Google account.</string>
<string name="upgrade_screen_sub_still_renewing_title">Subscription still active</string>
<string name="upgrade_screen_sub_still_renewing_message">Your subscription is still set to renew. Cancel it in Google Play first, then switch to the one-time purchase. Just cancelled? Give Google Play a moment and try again.</string>
<string name="upgrade_screen_sub_check_failed_title">Subscription check failed</string>
<string name="upgrade_screen_sub_check_failed_message">Couldn\'t check your subscription status with Google Play. To avoid a double purchase, try again in a moment.</string>
<string name="upgrade_screen_grace_title">Confirming your purchase</string>
<string name="upgrade_screen_grace_body_short">Google Play hasn\'t confirmed your purchase yet. Pro is still active — no action needed.</string>
<string name="upgrade_screen_grace_body">Google Play hasn\'t confirmed your purchase for a while. Pro is still active. Make sure you\'re online and signed in with the Google account used for the purchase, then try restoring.</string>
<string name="upgrade_foss_supporter_since">Supporter since %s</string>
<string name="upgrade_foss_supporter_thanks">Thank you for supporting CAPod\'s development!</string>
<string name="upgrade_foss_sponsor_again_action">Open sponsor page</string>
<string name="settings_monitor_connected_notification_label">Extra notification</string>
<string name="settings_monitor_connected_notification_description">Shows an extra notification when a device is connected. This lets you hide the permanent \"No devices\" notification by disabling the \"Device status\" channel.</string>
@@ -137,6 +164,10 @@
<string name="settings_category_debug_label">Debug</string>
<string name="settings_general_label">Settings</string>
<string name="settings_general_description">General tweaks that affect the whole app.</string>
<string name="settings_upgrade_status_gplay_label">CAPod Pro</string>
<string name="settings_upgrade_status_gplay_description">Your upgrade and purchase status.</string>
<string name="settings_upgrade_status_foss_label">Sponsor CAPod</string>
<string name="settings_upgrade_status_foss_description">Your supporter status.</string>
<string name="settings_overview_hide_unmatched_label">Hide unmatched devices</string>
<string name="settings_overview_hide_unmatched_description">Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods.</string>
@@ -0,0 +1,57 @@
package eu.darken.capod.upgrade.ui
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.time.Instant
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpgradeScreenFossComposeTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun `supporter status shows the supporter card`() {
composeRule.setContent {
SupporterStatusScreen(
upgradedAt = Instant.parse("2025-11-02T12:00:00Z"),
onNavigateUp = {},
onSponsorPage = {},
)
}
composeRule.onNodeWithTag("upgrade.foss.supporterCard").assertIsDisplayed()
}
@Test
fun `supporter sponsor link fires the plain callback`() {
var sponsorTapped = false
composeRule.setContent {
SupporterStatusScreen(
upgradedAt = Instant.parse("2025-11-02T12:00:00Z"),
onNavigateUp = {},
onSponsorPage = { sponsorTapped = true },
)
}
val label = androidx.test.core.app.ApplicationProvider
.getApplicationContext<android.content.Context>()
.getString(eu.darken.capod.R.string.upgrade_foss_sponsor_again_action)
composeRule.onNode(androidx.compose.ui.test.hasText(label)).performScrollTo().performClick()
sponsorTapped shouldBe true
}
}
@@ -3,11 +3,11 @@ package eu.darken.capod.common.upgrade.core
import android.app.Activity
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.datastore.createValue
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.FreshBillingData
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.longs.shouldBeGreaterThan
@@ -16,11 +16,12 @@ import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingResult
import eu.darken.capod.common.datastore.DataStoreValue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
@@ -34,6 +35,7 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.runTest2
import java.io.File
import java.time.Duration
@@ -44,28 +46,36 @@ class UpgradeRepoGplayTest : BaseTest() {
lateinit var tempDir: File
private lateinit var billingDataFlow: MutableSharedFlow<BillingData>
private lateinit var freshDataFlow: MutableSharedFlow<BillingData>
private lateinit var freshDataFlow: MutableSharedFlow<FreshBillingData>
private lateinit var refreshFailuresFlow: MutableSharedFlow<Unit>
private lateinit var billingDataRepo: BillingDataRepo
private lateinit var billingCache: BillingCache
private lateinit var timeSource: TestTimeSource
private var dsCounter = 0
private fun now(): Long = timeSource.currentTimeMillis()
@BeforeEach
fun setup() {
billingDataFlow = MutableSharedFlow()
freshDataFlow = MutableSharedFlow()
refreshFailuresFlow = MutableSharedFlow()
timeSource = TestTimeSource()
billingDataRepo = mockk {
every { billingData } returns billingDataFlow
every { freshBillingData } returns freshDataFlow
every { refreshFailures } returns refreshFailuresFlow
every { purchaseFailures } returns emptyFlow()
}
// Eager dispatcher: the cache's suspend writes must complete synchronously, or the
// virtual-time test clock races ahead of real-IO DataStore writes (and virtual timeouts
// fire mid-write).
val dataStore = PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher() + SupervisorJob()),
produceFile = { File(tempDir, "test_billing_cache_${dsCounter++}.preferences_pb") }
)
billingCache = mockk {
every { lastProStateAt } returns dataStore.createValue("gplay.cache.lastProAt", 0L)
every { lastProStateSku } returns dataStore.createValue("gplay.cache.lastProSku", "")
}
billingCache = BillingCache(dataStore)
}
private fun createRepo(scope: TestScope): UpgradeRepoGplay {
@@ -73,6 +83,7 @@ class UpgradeRepoGplayTest : BaseTest() {
scope = scope,
billingDataRepo = billingDataRepo,
billingCache = billingCache,
timeSource = timeSource,
)
}
@@ -81,6 +92,9 @@ class UpgradeRepoGplayTest : BaseTest() {
every { this@mockk.purchaseTime } returns purchaseTime
}
private fun freshData(purchases: Collection<Purchase>, isFullSnapshot: Boolean = true) =
FreshBillingData(data = BillingData(purchases = purchases), isFullSnapshot = isFullSnapshot)
@Test
fun `no purchases and no grace period - not pro`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
@@ -147,7 +161,7 @@ class UpgradeRepoGplayTest : BaseTest() {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Set last pro state to 1 hour ago (within 7-day window)
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 60 * 60 * 1000L
billingCache.lastProStateAt.valueBlocking = now() - 60 * 60 * 1000L
val repo = createRepo(testScope)
@@ -163,7 +177,7 @@ class UpgradeRepoGplayTest : BaseTest() {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Set last pro state to 1 hour ago (within 7-day window)
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 60 * 60 * 1000L
billingCache.lastProStateAt.valueBlocking = now() - 60 * 60 * 1000L
val repo = createRepo(testScope)
@@ -184,7 +198,7 @@ class UpgradeRepoGplayTest : BaseTest() {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Set last pro state to 8 days ago (beyond 7-day window)
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 8 * 24 * 60 * 60 * 1000L
billingCache.lastProStateAt.valueBlocking = now() - 8 * 24 * 60 * 60 * 1000L
val repo = createRepo(testScope)
@@ -229,7 +243,7 @@ class UpgradeRepoGplayTest : BaseTest() {
@Test
fun `restore returns pro when a purchase is found`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
val repo = createRepo(testScope)
@@ -244,8 +258,8 @@ class UpgradeRepoGplayTest : BaseTest() {
@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
coEvery { billingDataRepo.refresh() } returns freshData(purchases = emptyList())
billingCache.lastProStateAt.valueBlocking = now() - 1_000L
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe true
@@ -256,9 +270,8 @@ class UpgradeRepoGplayTest : BaseTest() {
@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
coEvery { billingDataRepo.refresh() } returns freshData(purchases = emptyList())
billingCache.lastProStateAt.valueBlocking = now() - UpgradeRepoGplay.GRACE_PERIOD_MS - 1_000L
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe false
@@ -270,7 +283,7 @@ class UpgradeRepoGplayTest : BaseTest() {
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
billingCache.lastProStateAt.valueBlocking = now() - 1_000L
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe true
@@ -294,9 +307,9 @@ class UpgradeRepoGplayTest : BaseTest() {
@Test
fun `permanent IAP keeps grace well beyond the subscription window`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList())
coEvery { billingDataRepo.refresh() } returns freshData(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.lastProStateAt.valueBlocking = now() - Duration.ofDays(20).toMillis()
billingCache.lastProStateSku.valueBlocking = CapodSku.Iap.PRO_UPGRADE.id
val repo = createRepo(testScope)
@@ -308,8 +321,8 @@ class UpgradeRepoGplayTest : BaseTest() {
@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()
coEvery { billingDataRepo.refresh() } returns freshData(purchases = emptyList())
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofDays(20).toMillis()
billingCache.lastProStateSku.valueBlocking = CapodSku.Sub.PRO_UPGRADE.id
val repo = createRepo(testScope)
@@ -321,8 +334,8 @@ class UpgradeRepoGplayTest : BaseTest() {
@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()
coEvery { billingDataRepo.refresh() } returns freshData(purchases = emptyList())
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofDays(20).toMillis()
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe false
@@ -333,7 +346,7 @@ class UpgradeRepoGplayTest : BaseTest() {
@Test
fun `confirmed pro purchase records the SKU for the grace window`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
val repo = createRepo(testScope)
@@ -349,8 +362,9 @@ class UpgradeRepoGplayTest : BaseTest() {
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))
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)),
isFullSnapshot = false,
)
billingCache.lastProStateSku.valueBlocking = CapodSku.Iap.PRO_UPGRADE.id
val repo = createRepo(testScope)
@@ -364,7 +378,7 @@ class UpgradeRepoGplayTest : BaseTest() {
@Test
fun `a subscription anchor is upgraded when an IAP purchase is confirmed`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
billingCache.lastProStateSku.valueBlocking = CapodSku.Sub.PRO_UPGRADE.id
@@ -399,7 +413,7 @@ class UpgradeRepoGplayTest : BaseTest() {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.startBillingFlow(any(), any(), any()) } throws
ItemAlreadyOwnedBillingException(RuntimeException("launch result"))
coEvery { billingDataRepo.refresh() } returns BillingData(
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
val repo = createRepo(testScope)
@@ -415,7 +429,7 @@ class UpgradeRepoGplayTest : BaseTest() {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.startBillingFlow(any(), any(), any()) } throws
ItemAlreadyOwnedBillingException(RuntimeException("launch result"))
coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList())
coEvery { billingDataRepo.refresh() } returns freshData(purchases = emptyList())
val repo = createRepo(testScope)
// Grace expired -> the restore can't rescue the entitlement either.
@@ -441,6 +455,25 @@ class UpgradeRepoGplayTest : BaseTest() {
testScope.cancel()
}
@Test
fun `already-owned recovery requires the LAUNCHED SKU, a different owned SKU does not reconcile`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.startBillingFlow(any(), any(), any()) } throws
ItemAlreadyOwnedBillingException(RuntimeException("launch result"))
// Launching the IAP, but the restore only returns the subscription — that doesn't explain
// the already-owned launch failure, so the user still needs the dialog with restore tips.
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id))
)
val repo = createRepo(testScope)
shouldThrow<ItemAlreadyOwnedBillingException> {
repo.launchBillingFlow(mockk<Activity>(), CapodSku.Iap.PRO_UPGRADE)
}
testScope.cancel()
}
private fun mockBillingResult(code: Int): BillingResult = mockk {
every { responseCode } returns code
every { debugMessage } returns "mock"
@@ -452,7 +485,7 @@ class UpgradeRepoGplayTest : BaseTest() {
createRepo(testScope)
// No upgradeInfo collection at all — the init collector alone must stamp.
freshDataFlow.emit(BillingData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))))
freshDataFlow.emit(freshData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))))
billingCache.lastProStateAt.valueBlocking shouldBeGreaterThan 0L
billingCache.lastProStateSku.valueBlocking shouldBe CapodSku.Iap.PRO_UPGRADE.id
@@ -463,34 +496,30 @@ class UpgradeRepoGplayTest : BaseTest() {
@Test
fun `the reactive mapping is read-only, only the collector stamps`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Count writes via mocked DataStore values: the cold freshBillingData flow delivers one
// pro observation to the init collector (1 stamp), and collecting upgradeInfo runs the
// Count stamps via a mocked cache: the cold freshBillingData flow delivers one pro
// observation to the init collector (1 stamp), and collecting upgradeInfo runs the
// mapping on top (onStart-null + pro data) — if the mapping still stamped, the count
// would exceed 1.
val proData = BillingData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)))
val lastProAtMock = mockk<DataStoreValue<Long>>(relaxed = true) {
every { flow } returns flowOf(0L)
}
val lastProSkuMock = mockk<DataStoreValue<String>>(relaxed = true) {
every { flow } returns flowOf("")
}
val cache = mockk<BillingCache> {
every { lastProStateAt } returns lastProAtMock
every { lastProStateSku } returns lastProSkuMock
val cache = mockk<BillingCache>(relaxed = true) {
every { lastProStateAt } returns mockk { every { flow } returns flowOf(0L) }
every { lastProStateSku } returns mockk { every { flow } returns flowOf("") }
}
val repo = UpgradeRepoGplay(
scope = testScope,
billingDataRepo = mockk {
every { billingData } returns flowOf(proData)
every { freshBillingData } returns flowOf(proData)
every { freshBillingData } returns flowOf(FreshBillingData(proData, isFullSnapshot = true))
every { refreshFailures } returns emptyFlow()
every { purchaseFailures } returns emptyFlow()
},
billingCache = cache,
timeSource = timeSource,
)
repo.upgradeInfo.first { it.isPro }.isPro shouldBe true
coVerify(exactly = 1) { lastProAtMock.update(any()) }
coVerify(exactly = 1) { cache.stampLastProState(any(), any()) }
testScope.cancel()
}
@@ -500,8 +529,8 @@ class UpgradeRepoGplayTest : BaseTest() {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// The connect-time query can complete before UpgradeRepoGplay is constructed — the
// observation stream carries replay=1 so that first Pro observation isn't lost.
val replayingFresh = MutableSharedFlow<BillingData>(replay = 1)
replayingFresh.tryEmit(BillingData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))))
val replayingFresh = MutableSharedFlow<FreshBillingData>(replay = 1)
replayingFresh.tryEmit(freshData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))))
every { billingDataRepo.freshBillingData } returns replayingFresh
createRepo(testScope)
@@ -517,27 +546,24 @@ class UpgradeRepoGplayTest : BaseTest() {
// Purchase equality dedupes the state flows for a steady owner — the observation stream
// must not dedupe, or a long-lived process would stop refreshing the grace timestamp.
val proData = BillingData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)))
val lastProAtMock = mockk<DataStoreValue<Long>>(relaxed = true) {
every { flow } returns flowOf(0L)
}
val lastProSkuMock = mockk<DataStoreValue<String>>(relaxed = true) {
every { flow } returns flowOf("")
}
val cache = mockk<BillingCache> {
every { lastProStateAt } returns lastProAtMock
every { lastProStateSku } returns lastProSkuMock
val proFresh = FreshBillingData(proData, isFullSnapshot = true)
val cache = mockk<BillingCache>(relaxed = true) {
every { lastProStateAt } returns mockk { every { flow } returns flowOf(0L) }
every { lastProStateSku } returns mockk { every { flow } returns flowOf("") }
}
UpgradeRepoGplay(
scope = testScope,
billingDataRepo = mockk {
every { billingData } returns emptyFlow()
every { freshBillingData } returns flowOf(proData, proData)
every { freshBillingData } returns flowOf(proFresh, proFresh)
every { refreshFailures } returns emptyFlow()
every { purchaseFailures } returns emptyFlow()
},
billingCache = cache,
timeSource = timeSource,
)
coVerify(exactly = 2) { lastProAtMock.update(any()) }
coVerify(exactly = 2) { cache.stampLastProState(any(), any()) }
testScope.cancel()
}
@@ -545,7 +571,7 @@ class UpgradeRepoGplayTest : BaseTest() {
@Test
fun `the same failure instance delivered twice triggers two restores`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
// Play reuses static BillingResult instances — a repeat of the same object must still
@@ -563,7 +589,7 @@ class UpgradeRepoGplayTest : BaseTest() {
@Test
fun `async already-owned purchase event triggers a silent restore`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(
coEvery { billingDataRepo.refresh() } returns freshData(
purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
every { billingDataRepo.purchaseFailures } returns
@@ -588,4 +614,169 @@ class UpgradeRepoGplayTest : BaseTest() {
testScope.cancel()
}
// --- Unconfirmed-episode clock ---
@Test
fun `a full snapshot without purchases starts the unconfirmed episode`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofMinutes(10).toMillis()
createRepo(testScope)
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = true))
billingCache.proUnconfirmedAt.valueBlocking shouldBe now()
testScope.cancel()
}
@Test
fun `presence-only data without purchases does not start an episode`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// A push payload or partial/raced query proves nothing about absence — it must never
// start the episode clock.
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofMinutes(10).toMillis()
createRepo(testScope)
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = false))
billingCache.proUnconfirmedAt.valueBlocking shouldBe 0L
testScope.cancel()
}
@Test
fun `a confirmed purchase atomically closes the open episode`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
billingCache.lastProStateAt.valueBlocking = now() - 2_000L
billingCache.proUnconfirmedAt.valueBlocking = now() - 1_000L
createRepo(testScope)
freshDataFlow.emit(freshData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))))
billingCache.proUnconfirmedAt.valueBlocking shouldBe 0L
billingCache.lastProStateAt.valueBlocking shouldBe now()
testScope.cancel()
}
@Test
fun `episode start is set-if-unset, follow-up failures keep the original stamp`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofMinutes(10).toMillis()
createRepo(testScope)
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = true))
val episodeStart = billingCache.proUnconfirmedAt.valueBlocking
episodeStart shouldBe now()
timeSource.advanceBy(Duration.ofHours(6))
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = true))
// Pushing the stamp forward would keep resetting the 24h diagnostics threshold.
billingCache.proUnconfirmedAt.valueBlocking shouldBe episodeStart
testScope.cancel()
}
@Test
fun `refresh failures start the episode clock`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Most refresh failures are swallowed by their pipelines (initial query timeout,
// foreground refresh errors) — the failure event stream must still start the episode,
// or a sustained outage would show "confirming..." forever.
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofMinutes(10).toMillis()
createRepo(testScope)
refreshFailuresFlow.emit(Unit)
billingCache.proUnconfirmedAt.valueBlocking shouldBe now()
testScope.cancel()
}
@Test
fun `no episode moments after a confirmation`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// A confirmation and a conflicting empty snapshot within the same minute is emission
// reordering around a racing purchase event, not a real unconfirmed state.
billingCache.lastProStateAt.valueBlocking = now() - 10_000L
createRepo(testScope)
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = true))
billingCache.proUnconfirmedAt.valueBlocking shouldBe 0L
testScope.cancel()
}
@Test
fun `grace expires while the flow stays collected`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// billingData is equality-deduped and kept hot by a process-lifetime subscriber — the
// deadline tick must flip isPro without any new billing emission.
billingCache.lastProStateAt.valueBlocking =
now() - UpgradeRepoGplay.GRACE_PERIOD_MS + Duration.ofMinutes(1).toMillis()
val repo = createRepo(testScope)
val emissions = mutableListOf<eu.darken.capod.common.upgrade.UpgradeRepo.Info>()
val job = testScope.launch { repo.upgradeInfo.toList(emissions) }
billingDataFlow.emit(BillingData(purchases = emptyList()))
emissions.last().isPro shouldBe true
timeSource.advanceBy(Duration.ofMinutes(2))
testScope.testScheduler.advanceTimeBy(Duration.ofMinutes(2).toMillis())
testScope.testScheduler.runCurrent()
emissions.last().isPro shouldBe false
job.cancel()
testScope.cancel()
}
@Test
fun `no episode without a prior confirmation`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Never-Pro users have no entitlement to be "unconfirmed" about.
createRepo(testScope)
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = true))
refreshFailuresFlow.emit(Unit)
billingCache.proUnconfirmedAt.valueBlocking shouldBe 0L
testScope.cancel()
}
@Test
fun `a corrupt future episode stamp is repaired`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofMinutes(10).toMillis()
// A stamp from the future (clock rollback, corrupt write) would push the diagnostics
// threshold out indefinitely — it must be replaced, not trusted.
billingCache.proUnconfirmedAt.valueBlocking = now() + Duration.ofDays(2).toMillis()
createRepo(testScope)
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = true))
billingCache.proUnconfirmedAt.valueBlocking shouldBe now()
testScope.cancel()
}
@Test
fun `proUnconfirmedSince exposes the cached episode start`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val repo = createRepo(testScope)
repo.proUnconfirmedSince.first() shouldBe 0L
billingCache.lastProStateAt.valueBlocking = now() - Duration.ofMinutes(10).toMillis()
freshDataFlow.emit(freshData(purchases = emptyList(), isFullSnapshot = true))
repo.proUnconfirmedSince.first() shouldBe now()
testScope.cancel()
}
}
@@ -1,28 +1,76 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResult
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.queryPurchasesAsync
import eu.darken.capod.common.upgrade.core.CapodSku
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
class BillingClientConnectionTest : BaseTest() {
@AfterEach
fun teardown() {
unmockkStatic("com.android.billingclient.api.BillingClientKotlinKt")
}
private fun mockPurchase(
productId: String = CapodSku.Iap.PRO_UPGRADE.id,
purchaseTime: Long = 1_000,
state: Int = Purchase.PurchaseState.PURCHASED,
token: String = "token-$productId-$purchaseTime",
): Purchase = mockk {
every { products } returns listOf(productId)
every { this@mockk.purchaseTime } returns purchaseTime
every { purchaseState } returns state
every { purchaseToken } returns token
}
private class Harness(
val purchasesGlobal: MutableStateFlow<Collection<Purchase>> = MutableStateFlow(emptySet()),
var generation: Long = 0L,
) {
val client = mockk<BillingClient>(relaxed = true)
val freshObservations = MutableSharedFlow<FreshPurchases>(replay = 1, extraBufferCapacity = 16)
val freshFailures = MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 8)
val connection = BillingClientConnection(
client = client,
purchasesGlobal = purchasesGlobal,
freshObservations = freshObservations,
freshFailuresGlobal = freshFailures,
purchaseFailuresGlobal = MutableSharedFlow(),
listenerGeneration = { generation },
)
init {
mockkStatic("com.android.billingclient.api.BillingClientKotlinKt")
}
fun okResult(purchases: List<Purchase>) = PurchasesResult(
BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK).build(),
purchases.toList(),
)
fun errorResult() = PurchasesResult(
BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.ERROR).build(),
emptyList(),
)
}
@Test
@@ -32,14 +80,177 @@ class BillingClientConnectionTest : BaseTest() {
val pending = mockPurchase(purchaseTime = 2_000, state = Purchase.PurchaseState.PENDING)
val purchased = mockPurchase(purchaseTime = 1_000, state = Purchase.PurchaseState.PURCHASED)
val connection = BillingClientConnection(
client = mockk(relaxed = true),
purchasesGlobal = MutableStateFlow(listOf(pending, purchased)),
freshObservations = MutableSharedFlow(),
purchaseFailuresGlobal = MutableSharedFlow(),
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(pending, purchased)))
harness.connection.purchases.first() shouldBe listOf(purchased)
}
@Test
fun `refresh with both queries OK is a full snapshot`() = runTest2 {
val harness = Harness()
val owned = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returnsMany listOf(
harness.okResult(listOf(owned)),
harness.okResult(emptyList()),
)
connection.purchases.first() shouldBe listOf(purchased)
val fresh = harness.connection.refreshPurchases()
fresh.purchases shouldBe listOf(owned)
fresh.isFullSnapshot shouldBe true
harness.freshObservations.first() shouldBe fresh
}
@Test
fun `refresh with a partial failure is presence-only`() = runTest2 {
// One type failed — the surviving type's authoritative purchase suppresses the error, but
// absence was NOT proven, so this refresh must not claim full-snapshot provenance (it
// could otherwise start a bogus unconfirmed episode for the failed type's entitlement).
val harness = Harness()
val ownedSub = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returnsMany listOf(
harness.errorResult(),
harness.okResult(listOf(ownedSub)),
)
val fresh = harness.connection.refreshPurchases()
fresh.purchases shouldBe listOf(ownedSub)
fresh.isFullSnapshot shouldBe false
}
@Test
fun `a purchase event racing the refresh downgrades the snapshot`() = runTest2 {
// The listener bumped its generation while the queries were in flight: the purchase it
// carries may be missing from our (older) query results, so an empty combined result must
// not claim to prove absence.
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } coAnswers {
harness.okResult(emptyList())
} coAndThen {
harness.generation += 1
harness.okResult(emptyList())
}
val fresh = harness.connection.refreshPurchases()
fresh.purchases shouldBe emptyList()
fresh.isFullSnapshot shouldBe false
}
@Test
fun `an inconclusive refresh throws and reports a fresh failure`() = runTest2 {
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returnsMany listOf(
harness.errorResult(),
harness.okResult(emptyList()),
)
shouldThrow<BillingResultException> {
harness.connection.refreshPurchases()
}
// The failure event is what starts the unconfirmed-episode clock during outages.
harness.freshFailures.first() shouldBe Unit
}
@Test
fun `querySubscriptions commits fresh state and supersedes stale listener records`() = runTest2 {
// The stale in-session record (isAutoRenewing=true from the purchase moment) and the
// fresh query record share a token — after the query, only the fresh one may survive,
// or ownership mapping would read "still renewing" forever.
val stale = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "shared-token")
val fresh = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "shared-token")
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(stale)))
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.okResult(listOf(fresh))
val result = harness.connection.querySubscriptions()
result shouldContainExactly listOf(fresh)
// Stale record superseded: the combined purchases view only contains the fresh record.
harness.connection.purchases.first() shouldContainExactly listOf(fresh)
// Presence-only provenance: a SUBS query proves nothing about the IAP.
harness.freshObservations.first().isFullSnapshot shouldBe false
}
@Test
fun `a subscription purchased while the query runs is still seen by the gate`() = runTest2 {
// The listener publishes a brand-new sub AND bumps the generation mid-query: the (older,
// empty) query result must neither prune nor hide it — over-blocking the switch is safe,
// missing a renewing sub is not.
val racing = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "race-token")
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } coAnswers {
harness.purchasesGlobal.value = listOf(racing)
harness.generation += 1
harness.okResult(emptyList())
}
val result = harness.connection.querySubscriptions()
result shouldContainExactly listOf(racing)
harness.connection.purchases.first() shouldContainExactly listOf(racing)
}
@Test
fun `a conclusive empty SUBS query prunes stale listener sub records`() = runTest2 {
// No purchase event raced the query, and the query proved absence for subscriptions —
// the stale in-session record (e.g. from a refunded purchase) must not resurrect Pro or
// block the switch until process restart.
val stale = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "stale-token")
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(stale)))
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.okResult(emptyList())
val result = harness.connection.querySubscriptions()
result shouldBe emptyList()
harness.connection.purchases.first() shouldBe emptyList()
}
@Test
fun `a conclusive empty full refresh prunes stale listener records`() = runTest2 {
val stale = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, token = "stale-token")
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(stale)))
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.okResult(emptyList())
val fresh = harness.connection.refreshPurchases()
fresh.isFullSnapshot shouldBe true
harness.connection.purchases.first() shouldBe emptyList()
}
@Test
fun `a raced refresh keeps newer listener records`() = runTest2 {
// Generation changed mid-refresh: the listener's record is newer than the query result,
// so nothing may be pruned and the snapshot must not claim to prove absence.
val newer = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "new-token")
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } coAnswers {
harness.purchasesGlobal.value = listOf(newer)
harness.generation += 1
harness.okResult(emptyList())
}
val fresh = harness.connection.refreshPurchases()
fresh.isFullSnapshot shouldBe false
harness.connection.purchases.first() shouldContainExactly listOf(newer)
}
@Test
fun `querySubscriptions failure propagates and reports a fresh failure`() = runTest2 {
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.errorResult()
shouldThrow<BillingResultException> {
harness.connection.querySubscriptions()
}
harness.freshFailures.first() shouldBe Unit
}
@Test
@@ -11,19 +11,23 @@ import eu.darken.capod.common.upgrade.core.client.BillingResultException
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.client.ItemAlreadyOwnedBillingException
import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException
import eu.darken.capod.common.upgrade.core.client.FreshPurchases
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo.Companion.tryMapUserFriendly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.jupiter.api.Test
@@ -213,9 +217,14 @@ class BillingDataRepoTest : BaseTest() {
mapped shouldBe original
}
private fun mockPurchase(state: Int, acknowledged: Boolean): Purchase = mockk {
private fun mockPurchase(
state: Int,
acknowledged: Boolean,
token: String = "token-$state-$acknowledged",
): Purchase = mockk {
every { purchaseState } returns state
every { isAcknowledged } returns acknowledged
every { purchaseToken } returns token
}
@Test
@@ -253,7 +262,8 @@ class BillingDataRepoTest : BaseTest() {
private class ForegroundRefreshHarness(testScope: TestScope) {
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow()
coEvery { refreshPurchases() } returns emptyList()
every { freshFailures } returns emptyFlow()
coEvery { refreshPurchases() } returns FreshPurchases(emptyList(), isFullSnapshot = true)
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(this@ForegroundRefreshHarness.clientConnection)
@@ -313,4 +323,129 @@ class BillingDataRepoTest : BaseTest() {
testScope.cancel()
}
@Test
fun `a successfully acked purchase is not re-acked when a stale snapshot re-emits`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// The immutable Purchase snapshot keeps claiming isAcknowledged=false until a fresh query
// supersedes it — re-emissions of that stale view must not re-ack the same purchase.
val stale = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = false)
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns flowOf(listOf(stale), listOf(stale), listOf(stale))
coEvery { acknowledgePurchase(any()) } returns Unit
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.runCurrent()
coVerify(exactly = 1) { clientConnection.acknowledgePurchase(stale) }
} finally {
testScope.cancel()
}
}
@Test
fun `a FAILED ack is not suppressed and still retries`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Only a SUCCESSFUL ack may enter the suppression set — a failure must stay retryable.
val unacked = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = false)
var ackAttempts = 0
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns flowOf(listOf(unacked))
coEvery { acknowledgePurchase(any()) } coAnswers {
ackAttempts++
if (ackAttempts == 1) {
throw BillingResultException(mockBillingResult(BillingClient.BillingResponseCode.ERROR))
}
}
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 2) { clientConnection.acknowledgePurchase(unacked) }
} finally {
testScope.cancel()
}
}
@Test
fun `a foreground refresh timeout is reported as a refresh failure`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// A hung refresh is cancelled by the foreground pipeline's timeout BEFORE the query's own
// failure path can signal anything — without this local report, a sustained outage would
// never start the unconfirmed-episode clock.
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow()
every { freshFailures } returns emptyFlow()
coEvery { refreshPurchases() } coAnswers { awaitCancellation() }
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foreground = MutableStateFlow(false)
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns foreground
}
try {
val repo = BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
val failures = mutableListOf<Unit>()
val collectJob = testScope.launch { repo.refreshFailures.toList(failures) }
foreground.value = true
testScope.testScheduler.advanceTimeBy(31_000)
testScope.testScheduler.runCurrent()
failures.size shouldBe 1
collectJob.cancel()
} finally {
testScope.cancel()
}
}
@Test
fun `querySubscriptions delegates to the connection and maps errors`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow()
every { freshFailures } returns emptyFlow()
coEvery { querySubscriptions() } throws BillingResultException(
mockBillingResult(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
)
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
val repo = BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
// The strict SUBS gate propagates errors, mapped to the user-friendly type so the
// caller's error dialog can tell "Play unavailable" apart from other failures.
shouldThrow<GplayServiceUnavailableException> {
repo.querySubscriptions()
}
} finally {
testScope.cancel()
}
}
}
@@ -0,0 +1,264 @@
package eu.darken.capod.upgrade.ui
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
// Behavioral Compose tests for the upgrade screen states — offer visibility, enabled states,
// grace stages and dialogs. Runs on the JVM via Robolectric (vintage engine under JUnit5).
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpgradeScreenComposeTest {
@get:Rule
val composeRule = createComposeRule()
private fun loaded(
ownership: Ownership = Ownership(),
grace: GraceHint? = null,
showRestoreBanner: Boolean = false,
settledEnabled: Boolean = true,
restoreInProgress: Boolean = false,
verificationInProgress: Boolean = false,
) = UpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.TRIAL,
subscriptionEnabled = settledEnabled && ownership.subscription == null && !restoreInProgress,
subscriptionPrice = "€3.49",
iapEnabled = settledEnabled && !ownership.hasIap && !restoreInProgress,
iapPrice = "€6.49",
ownership = ownership,
grace = grace,
showRestoreBanner = showRestoreBanner,
settled = settledEnabled,
restoreInProgress = restoreInProgress,
verificationInProgress = verificationInProgress,
)
private fun setScreen(
state: UpgradeUiState,
onSubscription: () -> Unit = {},
onIap: () -> Unit = {},
onRestore: () -> Unit = {},
onManageSubscription: () -> Unit = {},
) {
composeRule.setContent {
UpgradeScreen(
state = state,
onNavigateUp = {},
onSubscription = onSubscription,
onSubscriptionTrial = onSubscription,
onIap = onIap,
onRestore = onRestore,
onManageSubscription = onManageSubscription,
)
}
}
// --- Owner states ---
@Test
fun `owner with renewing sub sees status and a locked switch`() {
setScreen(loaded(ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true))))
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_HERO).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_SUB_CARD).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
// No sales pitch, no acquisition buttons for owners.
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).assertDoesNotExist()
}
@Test
fun `owner with non-renewing sub can use the switch`() {
var iapTapped = false
setScreen(
loaded(ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false))),
onIap = { iapTapped = true },
)
val button = composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo()
button.assertIsEnabled()
button.performClick()
iapTapped shouldBe true
}
@Test
fun `iap owner sees the ownership card but no switch or manage actions`() {
setScreen(loaded(ownership = Ownership(hasIap = true)))
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_HERO).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_IAP_CARD).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_CARD).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.MANAGE_SUB_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_WARNING).assertDoesNotExist()
}
@Test
fun `owning both while the sub still renews shows the double-payment warning`() {
setScreen(
loaded(
ownership = Ownership(
hasIap = true,
subscription = SubscriptionOwnership(isAutoRenewing = true),
),
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_WARNING).performScrollTo().assertIsDisplayed()
// Both products owned -> no switch offer.
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_CARD).assertDoesNotExist()
}
@Test
fun `manage subscription fires the callback`() {
var managed = false
setScreen(
loaded(ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true))),
onManageSubscription = { managed = true },
)
composeRule.onNodeWithTag(UpgradeScreenTags.MANAGE_SUB_BUTTON).performScrollTo().performClick()
managed shouldBe true
}
@Test
fun `the switch honors the settled gate`() {
setScreen(
loaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
settledEnabled = false,
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
}
@Test
fun `verification in progress disables the unlocked switch`() {
setScreen(
loaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
verificationInProgress = true,
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
}
// --- Grace states ---
@Test
fun `quiet grace stage hides offers and restore`() {
setScreen(loaded(grace = GraceHint(showDiagnostics = false)))
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_CARD).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertDoesNotExist()
}
@Test
fun `diagnostics grace stage shows restore and brings the offers back`() {
var restoreTapped = false
setScreen(
loaded(grace = GraceHint(showDiagnostics = true)),
onRestore = { restoreTapped = true },
)
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_CARD).assertIsDisplayed()
// Offers return so an actually-expired subscriber can switch without waiting out grace.
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON).performScrollTo().performClick()
restoreTapped shouldBe true
}
// --- Acquisition states ---
@Test
fun `acquisition shows pitch and enabled purchase buttons`() {
setScreen(loaded())
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_HERO).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_CARD).assertDoesNotExist()
}
@Test
fun `purchase buttons are disabled before billing has settled`() {
setScreen(loaded(settledEnabled = false))
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsNotEnabled()
}
@Test
fun `restore banner appears for returning buyers`() {
setScreen(loaded(showRestoreBanner = true))
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BANNER).performScrollTo().assertIsDisplayed()
}
@Test
fun `restore in progress disables purchase and restore buttons`() {
setScreen(loaded(restoreInProgress = true))
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BUTTON).performScrollTo().assertIsNotEnabled()
}
// --- Dialogs ---
@Test
fun `still-renewing dialog offers the manage action`() {
var managed = false
var dismissed = false
composeRule.setContent {
StillRenewingDialog(onManage = { managed = true }, onDismiss = { dismissed = true })
}
composeRule.onNodeWithTag(UpgradeScreenTags.DIALOG_STILL_RENEWING).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.MANAGE_SUB_BUTTON).performClick()
managed shouldBe true
dismissed shouldBe false
}
@Test
fun `check-failed dialog renders`() {
composeRule.setContent {
CheckFailedDialog(onDismiss = {})
}
composeRule.onNodeWithTag(UpgradeScreenTags.DIALOG_CHECK_FAILED).assertIsDisplayed()
}
@Test
fun `restore-failed dialog renders the troubleshooting hints`() {
composeRule.setContent {
RestoreFailedDialog(onDismiss = {})
}
composeRule.onNodeWithTag(UpgradeScreenTags.DIALOG_RESTORE_FAILED).assertIsDisplayed()
}
}
@@ -1,14 +1,24 @@
package eu.darken.capod.upgrade.ui
import android.app.Activity
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.navigation.NavEvent
import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException
import eu.darken.capod.common.upgrade.core.data.BillingData
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
@@ -18,20 +28,54 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.coroutine.runTest2
import java.time.Duration
class UpgradeViewModelTest : BaseTest() {
private val timeSource = TestTimeSource()
private fun now(): Long = timeSource.currentTimeMillis()
private fun mockPurchase(productId: String, autoRenewing: Boolean = false): Purchase = mockk {
every { products } returns listOf(productId)
every { purchaseTime } returns 1_000L
every { isAutoRenewing } returns autoRenewing
}
private fun ownerInfo(vararg purchases: Purchase): UpgradeRepoGplay.Info {
val purchased = purchases.map { purchase ->
val sku = CapodSku.PRO_SKUS.first { it.id in purchase.products }
PurchasedSku(sku, purchase)
}
return UpgradeRepoGplay.Info(
billingData = BillingData(purchases.toList()),
upgrades = purchased,
)
}
private fun mockRepo(): UpgradeRepoGplay = mockk<UpgradeRepoGplay>(relaxed = true).apply {
every { upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(billingData = null))
every { wasEverPro } returns MutableStateFlow(false)
every { proUnconfirmedSince } returns MutableStateFlow(0L)
every { isSettled } returns MutableStateFlow(true)
coEvery { queryCurrentSubscriptions() } returns emptyList()
coEvery { querySkus(any()) } returns emptyList()
}
private fun TestScope.createVm(repo: UpgradeRepoGplay) = UpgradeViewModel(
private fun TestScope.createVm(
repo: UpgradeRepoGplay,
manage: Boolean? = false,
) = UpgradeViewModel(
dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher(testScheduler)),
upgradeRepo = repo,
)
webpageTool = mockk<WebpageTool>(relaxed = true),
timeSource = timeSource,
).also { vm -> manage?.let { vm.initialize(it) } }
// --- Restore semantics ---
@Test
fun `restore with no purchase emits RestoreFailed`() = runTest2 {
@@ -47,7 +91,9 @@ class UpgradeViewModelTest : BaseTest() {
}
@Test
fun `restore that finds a purchase stays silent`() = runTest2 {
fun `a grace-only restore result is NOT a success`() = runTest2 {
// isPro via grace means Play still couldn't confirm anything — the user must get the
// troubleshooting dialog, not a success message.
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(
gracePeriod = true,
@@ -55,20 +101,24 @@ class UpgradeViewModelTest : BaseTest() {
)
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) } }
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 1) { repo.restorePurchaseNow() }
events shouldBe emptyList()
errors shouldBe emptyList()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
}
eventJob.cancel()
errorJob.cancel()
@Test
fun `restore that finds an actual purchase emits RestoreSucceeded`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns ownerInfo(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreSucceeded
}
@Test
@@ -101,6 +151,31 @@ class UpgradeViewModelTest : BaseTest() {
forwardedError.await() shouldBe boom
}
@Test
fun `restore results are padded to a minimum visible duration`() = runTest2 {
// The repo answers instantly here (warm cache) — the user must still see the check "run":
// the result event may only surface once RESTORE_MIN_VISIBLE_MS elapsed.
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(billingData = null)
val vm = createVm(repo)
val events = mutableListOf<UpgradeViewModel.UpgradeEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.events.collect { events.add(it) } }
vm.restorePurchase()
testScheduler.advanceTimeBy(UpgradeViewModel.RESTORE_MIN_VISIBLE_MS - 100)
testScheduler.runCurrent()
events shouldBe emptyList()
testScheduler.advanceTimeBy(200)
testScheduler.runCurrent()
events shouldBe listOf(UpgradeViewModel.UpgradeEvent.RestoreFailed)
job.cancel()
}
@Test
fun `restore is single-flight, taps during a running restore are ignored`() = runTest2 {
val repo = mockRepo()
@@ -118,43 +193,6 @@ class UpgradeViewModelTest : BaseTest() {
coVerify(exactly = 1) { repo.restorePurchaseNow() }
}
@Test
fun `restoreInProgress is set while a restore is running and cleared after`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
}
val vm = createVm(repo)
val states = mutableListOf<UpgradeViewModel.RestoreState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } }
vm.restorePurchase()
advanceUntilIdle()
states.any { it.restoreInProgress } shouldBe true
states.last().restoreInProgress shouldBe false
job.cancel()
}
@Test
fun `buy taps are ignored while a restore is running`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
}
val vm = createVm(repo)
vm.restorePurchase()
vm.launchBillingIap(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `a finished restore allows a new attempt`() = runTest2 {
val repo = mockRepo()
@@ -169,38 +207,152 @@ class UpgradeViewModelTest : BaseTest() {
coVerify(exactly = 2) { repo.restorePurchaseNow() }
}
// --- Switch-to-IAP gate ---
@Test
fun `banner shows for a previously-pro install that is no longer pro`() = runTest2 {
fun `every IAP tap runs the fresh SUBS gate, even for an apparent non-owner`() = runTest2 {
// upgradeInfo says non-owner (stale/empty early state), but the fresh query finds a
// renewing subscription — the launch must stay blocked.
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
coEvery { repo.queryCurrentSubscriptions() } returns
listOf(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true))
val vm = createVm(repo)
val states = mutableListOf<UpgradeViewModel.RestoreState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } }
val event = async { vm.events.first() }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
states.last().showRestoreBanner shouldBe true
job.cancel()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.SubscriptionStillRenewing
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `banner stays hidden while grace still keeps the user pro`() = runTest2 {
fun `IAP launch proceeds when no subscription is renewing`() = runTest2 {
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
// gracePeriod = true -> isPro is true even without a current raw purchase.
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
coEvery { repo.queryCurrentSubscriptions() } returns
listOf(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = false))
val vm = createVm(repo)
val states = mutableListOf<UpgradeViewModel.RestoreState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
states.last().showRestoreBanner shouldBe false
coVerify(exactly = 1) { repo.launchBillingFlow(any(), CapodSku.Iap.PRO_UPGRADE, null) }
}
job.cancel()
@Test
fun `a timed-out subscription check fails closed`() = runTest2 {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(UpgradeViewModel.VERIFY_TIMEOUT_MS * 2)
emptyList()
}
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.SubscriptionCheckFailed
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `a failed subscription check fails closed with an error dialog`() = runTest2 {
val repo = mockRepo()
val boom = IllegalStateException("Play unavailable")
coEvery { repo.queryCurrentSubscriptions() } throws boom
val vm = createVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
forwardedError.await() shouldBe boom
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `purchase actions are single-flight while the verification is suspended`() = runTest2 {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(5_000)
emptyList()
}
val vm = createVm(repo)
vm.onGoIap(mockk<Activity>())
vm.onGoIap(mockk<Activity>())
vm.onGoSubscription(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
// Only the IAP launch after its successful verification; the sub tap was swallowed.
coVerify(exactly = 1) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `restore taps are ignored while a purchase action is in flight`() = runTest2 {
// Symmetric exclusion: otherwise a verification resolving to a dialog and a concurrent
// restore resolving to another dialog stack on screen.
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(5_000)
emptyList()
}
val vm = createVm(repo)
vm.onGoIap(mockk<Activity>())
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 0) { repo.restorePurchaseNow() }
}
@Test
fun `resume refreshes the subscription state for sub owners`() = runTest2 {
// Returning from Play's Manage page must unlock the switch promptly — the global
// foreground refresh is throttled to once an hour.
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
ownerInfo(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true))
)
val vm = createVm(repo)
vm.state.first { it is UpgradeUiState.Loaded }
vm.onResume()
advanceUntilIdle()
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
}
@Test
fun `resume does not query Play for non-subscribers`() = runTest2 {
val repo = mockRepo()
val vm = createVm(repo)
vm.state.first { it is UpgradeUiState.Loaded }
vm.onResume()
advanceUntilIdle()
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
}
@Test
fun `purchase taps are ignored while a restore is running`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
}
val vm = createVm(repo)
vm.restorePurchase()
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
@@ -213,7 +365,7 @@ class UpgradeViewModelTest : BaseTest() {
val errors = mutableListOf<Throwable>()
val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } }
vm.launchBillingIap(mockk<Activity>())
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 1) { repo.launchBillingFlow(any(), any(), any()) }
@@ -230,9 +382,250 @@ class UpgradeViewModelTest : BaseTest() {
val vm = createVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.launchBillingIap(mockk<Activity>())
vm.onGoSubscription(mockk<Activity>())
advanceUntilIdle()
forwardedError.await() shouldBe boom
}
// --- Route handling / auto-close ---
@Test
fun `the sales route closes once the user is pro`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
val vm = createVm(repo, manage = null)
val navEvent = async { vm.navEvents.first() }
vm.initialize(manage = false)
advanceUntilIdle()
navEvent.await() shouldBe NavEvent.Up
}
@Test
fun `the manage route never auto-closes`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
val vm = createVm(repo, manage = true)
val navEvents = mutableListOf<NavEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.navEvents.collect { navEvents.add(it) } }
advanceUntilIdle()
navEvents shouldBe emptyList()
job.cancel()
}
@Test
fun `no auto-close before the route is bound`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
val vm = createVm(repo, manage = null)
val navEvents = mutableListOf<NavEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.navEvents.collect { navEvents.add(it) } }
advanceUntilIdle()
navEvents shouldBe emptyList()
job.cancel()
}
// --- State mapping ---
@Test
fun `owners render price-independently while SKU queries hang`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
ownerInfo(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true))
)
coEvery { repo.querySkus(any()) } coAnswers { awaitCancellation() }
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded }
state.shouldBeInstanceOf<UpgradeUiState.Loaded>()
state.ownership.subscription.shouldNotBeNull()
state.ownership.subscription!!.isAutoRenewing shouldBe true
state.iapPrice.shouldBeNull()
}
@Test
fun `grace users see the quiet hint before the diagnostics threshold`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(1).toMillis())
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.grace.shouldNotBeNull()
state.grace!!.showDiagnostics shouldBe false
}
@Test
fun `grace escalates to diagnostics after the threshold`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(25).toMillis())
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.grace.shouldNotBeNull()
state.grace!!.showDiagnostics shouldBe true
}
@Test
fun `the grace tick re-evaluates when the episode crosses the threshold`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
// 2 minutes before the diagnostics threshold.
every { repo.proUnconfirmedSince } returns MutableStateFlow(
now() - UpgradeViewModel.GRACE_DIAGNOSTICS_AFTER_MS + Duration.ofMinutes(2).toMillis()
)
val vm = createVm(repo)
val states = mutableListOf<UpgradeUiState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect { states.add(it) } }
// runCurrent, NOT advanceUntilIdle: idling would fast-forward the virtual clock through
// the tick's delay while the wall-clock TestTimeSource still sits before the threshold.
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).grace!!.showDiagnostics shouldBe false
// All other combined flows are distinct-until-changed — only the tick can re-fire.
timeSource.advanceBy(Duration.ofMinutes(3))
testScheduler.advanceTimeBy(Duration.ofMinutes(3).toMillis())
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).grace!!.showDiagnostics shouldBe true
job.cancel()
}
@Test
fun `no grace hint for confirmed owners`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
ownerInfo(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(25).toMillis())
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.grace.shouldBeNull()
state.ownership.hasIap shouldBe true
}
@Test
fun `banner shows for a previously-pro install that is no longer pro`() = runTest2 {
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.showRestoreBanner shouldBe true
}
@Test
fun `banner stays hidden while grace still keeps the user pro`() = runTest2 {
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.showRestoreBanner shouldBe false
}
@Test
fun `purchase buttons stay disabled until billing has settled`() = runTest2 {
val repo = mockRepo()
val settledFlow = MutableStateFlow(false)
every { repo.isSettled } returns settledFlow
val vm = createVm(repo)
val states = mutableListOf<UpgradeUiState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect { states.add(it) } }
// runCurrent, NOT advanceUntilIdle: idling would run the bounded settle-fallback timer
// and defeat the point of the test.
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe false
(states.last() as UpgradeUiState.Loaded).subscriptionEnabled shouldBe false
settledFlow.value = true
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe true
job.cancel()
}
// --- Pure mappers ---
@Test
fun `toOwnership is conservative about auto-renewal`() {
// Two records for the sub SKU, one still claiming renewal -> treated as renewing.
val renewing = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true)
val notRenewing = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = false)
val info = ownerInfo(notRenewing, renewing)
val ownership = info.toOwnership()
ownership.subscription.shouldNotBeNull()
ownership.subscription!!.isAutoRenewing shouldBe true
ownership.hasIap shouldBe false
}
@Test
fun `toOwnership maps both product types`() {
val info = ownerInfo(
mockPurchase(CapodSku.Iap.PRO_UPGRADE.id),
mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = false),
)
val ownership = info.toOwnership()
ownership.hasIap shouldBe true
ownership.subscription.shouldNotBeNull()
ownership.subscription!!.isAutoRenewing shouldBe false
ownership.ownsAnything shouldBe true
}
@Test
fun `toLoadedState disables owned products`() {
val state = toLoadedState(
skus = SkuQueryState(done = true),
ownership = Ownership(hasIap = true, subscription = SubscriptionOwnership(isAutoRenewing = true)),
grace = null,
showRestoreBanner = false,
settled = true,
restoreInProgress = false,
verificationInProgress = false,
)
state.iapEnabled shouldBe false
state.subscriptionEnabled shouldBe false
}
}