mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-10
@@ -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
|
||||
|
||||
+32
-4
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+249
-58
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+217
-6
@@ -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
|
||||
|
||||
+137
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user