From 825892df74392d36d98b252effbc3ab09175d569 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Wed, 22 Jul 2026 15:40:57 +0200 Subject: [PATCH] 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 --- app/build.gradle.kts | 6 + .../capod/upgrade/ui/UpgradeNavigation.kt | 2 +- .../darken/capod/upgrade/ui/UpgradeScreen.kt | 174 +++- .../capod/upgrade/ui/UpgradeViewModel.kt | 22 + .../capod/common/upgrade/core/BillingCache.kt | 68 +- .../common/upgrade/core/UpgradeRepoGplay.kt | 158 ++- .../core/client/BillingClientConnection.kt | 117 ++- .../client/BillingClientConnectionProvider.kt | 36 +- .../upgrade/core/client/FreshPurchases.kt | 11 + .../common/upgrade/core/data/BillingData.kt | 8 + .../upgrade/core/data/BillingDataRepo.kt | 56 +- .../capod/upgrade/ui/UpgradeNavigation.kt | 2 +- .../darken/capod/upgrade/ui/UpgradeScreen.kt | 904 ++++++++++++++---- .../darken/capod/upgrade/ui/UpgradeUiState.kt | 98 ++ .../capod/upgrade/ui/UpgradeViewModel.kt | 392 +++++--- .../eu/darken/capod/common/navigation/Nav.kt | 2 +- .../eu/darken/capod/main/ui/MainActivity.kt | 2 +- .../devicesettings/DeviceSettingsViewModel.kt | 18 +- .../main/ui/overview/OverviewViewModel.kt | 2 +- .../presscontrols/PressControlsViewModel.kt | 2 +- .../capod/main/ui/settings/SettingsScreen.kt | 22 + .../main/ui/settings/SettingsViewModel.kt | 9 +- .../general/GeneralSettingsViewModel.kt | 8 +- app/src/main/res/values/strings.xml | 31 + .../ui/UpgradeScreenFossComposeTest.kt | 57 ++ .../upgrade/core/UpgradeRepoGplayTest.kt | 307 ++++-- .../client/BillingClientConnectionTest.kt | 223 ++++- .../upgrade/core/data/BillingDataRepoTest.kt | 139 ++- .../upgrade/ui/UpgradeScreenComposeTest.kt | 264 +++++ .../capod/upgrade/ui/UpgradeViewModelTest.kt | 531 ++++++++-- 30 files changed, 3134 insertions(+), 537 deletions(-) create mode 100644 app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/FreshPurchases.kt create mode 100644 app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeUiState.kt create mode 100644 app/src/testFoss/java/eu/darken/capod/upgrade/ui/UpgradeScreenFossComposeTest.kt create mode 100644 app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5dde0c43..3a27f998 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt index b0b460e5..eb1b7bee 100644 --- a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt +++ b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt @@ -13,7 +13,7 @@ import javax.inject.Inject class UpgradeNavigation @Inject constructor() : NavigationEntry { override fun EntryProviderScope.setup() { - entry { UpgradeScreenHost() } + entry { key -> UpgradeScreenHost(manage = key.manage) } } @Module diff --git a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 4faf1e7f..e6659e30 100644 --- a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -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 = {}, + ) +} diff --git a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt index b2f2aab9..b08f90dc 100644 --- a/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/foss/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt @@ -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() + 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 = 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(KEY_SPONSOR_OPENED_AT) ?: return savedStateHandle.remove(KEY_SPONSOR_OPENED_AT) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt index a84bc31d..79d8c55f 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt @@ -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 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, ) { - 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 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 + } } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index 17114da1..6078e3a4 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -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 = billingDataRepo.billingData - .map { 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 = 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 = combine( + billingDataRepo.billingData + .map { 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 = 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 = 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 = 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 } } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt index 78e97815..0e8d3440 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt @@ -35,9 +35,11 @@ import kotlinx.coroutines.sync.withLock data class BillingClientConnection( private val client: BillingClient, - private val purchasesGlobal: Flow>, - private val freshObservations: MutableSharedFlow>, + private val purchasesGlobal: MutableStateFlow>, + private val freshObservations: MutableSharedFlow, + private val freshFailuresGlobal: MutableSharedFlow, private val purchaseFailuresGlobal: Flow, + 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> = freshObservations + // Each observation carries provenance: only a full snapshot proves absence. + val freshPurchases: Flow = 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 = freshFailuresGlobal + private data class QueryCaches( val iaps: Collection? = null, val subs: Collection? = 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> = 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 = refreshLock.withLock { + suspend fun refreshPurchases(): FreshPurchases = refreshLock.withLock { refreshPurchasesLocked() } - private suspend fun refreshPurchasesLocked(): Collection = 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 = 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, + 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 diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt index 615a2d37..305aa9af 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt @@ -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>( + val freshPurchaseObservations = MutableSharedFlow( 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( + 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) { diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/FreshPurchases.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/FreshPurchases.kt new file mode 100644 index 00000000..2582787c --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/FreshPurchases.kt @@ -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, + val isFullSnapshot: Boolean, +) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt index 56e825b8..06dde4c2 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt @@ -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, +) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt index 97f39305..a606fc01 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt @@ -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 = connectionProvider + // data, so it is the only valid source for grace stamping. Carries provenance: only full + // snapshots may prove absence. + val freshBillingData: Flow = 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(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 = 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() + 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 = try { + connectionKicks.tryEmit(Unit) + val clientConnection = connectionProvider.first() + clientConnection.querySubscriptions() } catch (e: Exception) { throw e.tryMapUserFriendly() } diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt index b0b460e5..eb1b7bee 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeNavigation.kt @@ -13,7 +13,7 @@ import javax.inject.Inject class UpgradeNavigation @Inject constructor() : NavigationEntry { override fun EntryProviderScope.setup() { - entry { UpgradeScreenHost() } + entry { key -> UpgradeScreenHost(manage = key.manage) } } @Module diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 1fe2de25..65bccf10 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -19,14 +19,15 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.twotone.ArrowBack import androidx.compose.material.icons.automirrored.twotone.Message -import androidx.compose.material.icons.twotone.BluetoothConnected import androidx.compose.material.icons.twotone.Favorite import androidx.compose.material.icons.twotone.Palette import androidx.compose.material.icons.twotone.PlayCircle import androidx.compose.material.icons.twotone.Stars import androidx.compose.material.icons.twotone.Headphones import androidx.compose.material.icons.twotone.Tune +import androidx.compose.material.icons.twotone.Verified import androidx.compose.material.icons.twotone.Widgets +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -39,14 +40,20 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -57,56 +64,176 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import eu.darken.capod.R 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 +object UpgradeScreenTags { + const val SUB_BUTTON = "upgrade.sub.button" + const val IAP_BUTTON = "upgrade.iap.button" + const val RESTORE_BUTTON = "upgrade.restore.button" + const val RESTORE_BANNER = "upgrade.restore.banner" + const val OWNER_HERO = "upgrade.owner.hero" + const val OWNER_SUB_CARD = "upgrade.owner.subCard" + const val OWNER_IAP_CARD = "upgrade.owner.iapCard" + const val OWNER_WARNING = "upgrade.owner.bothOwnedWarning" + const val MANAGE_SUB_BUTTON = "upgrade.manageSub.button" + const val SWITCH_CARD = "upgrade.switch.card" + const val SWITCH_BUTTON = "upgrade.switch.button" + const val GRACE_CARD = "upgrade.grace.card" + const val GRACE_RESTORE_BUTTON = "upgrade.grace.restore" + const val DIALOG_STILL_RENEWING = "upgrade.dialog.stillRenewing" + const val DIALOG_CHECK_FAILED = "upgrade.dialog.checkFailed" + const val DIALOG_RESTORE_FAILED = "upgrade.dialog.restoreFailed" + const val BENEFITS = "upgrade.benefits" +} @Composable -fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) { +fun UpgradeScreenHost( + manage: Boolean = false, + vm: UpgradeViewModel = hiltViewModel(), +) { ErrorEventHandler(vm) NavigationEventHandler(vm) + // Bind the route BEFORE anything else can race the auto-close collector. + LaunchedEffect(manage) { vm.initialize(manage) } + val context = LocalContext.current val activity = context as? Activity val state by vm.state.collectAsState() - val restoreState by vm.restoreState.collectAsState() + + var showStillRenewingDialog by remember { mutableStateOf(false) } + var showCheckFailedDialog by remember { mutableStateOf(false) } + var showRestoreFailedDialog by remember { mutableStateOf(false) } + + val restoreSuccessMessage = stringResource(R.string.upgrade_screen_restore_success_message) LaunchedEffect(Unit) { vm.events.collect { event -> when (event) { - UpgradeViewModel.UpgradeEvent.RestoreFailed -> { - Toast.makeText( - context, - R.string.upgrade_screen_restore_purchase_message, - Toast.LENGTH_LONG - ).show() + UpgradeViewModel.UpgradeEvent.RestoreFailed -> showRestoreFailedDialog = true + UpgradeViewModel.UpgradeEvent.RestoreSucceeded -> { + Toast.makeText(context, restoreSuccessMessage, Toast.LENGTH_LONG).show() } + + UpgradeViewModel.UpgradeEvent.SubscriptionStillRenewing -> showStillRenewingDialog = true + UpgradeViewModel.UpgradeEvent.SubscriptionCheckFailed -> showCheckFailedDialog = true } } } - LaunchedEffect(Unit) { - vm.billingEvents.collect { event -> - activity ?: return@collect - when (event) { - UpgradeViewModel.BillingEvent.LaunchIap -> vm.launchBillingIap(activity) - UpgradeViewModel.BillingEvent.LaunchSubscription -> vm.launchBillingSubscription(activity) - UpgradeViewModel.BillingEvent.LaunchSubscriptionTrial -> vm.launchBillingSubscriptionTrial(activity) - } + // Returning from Play's subscription-management page must refresh the renewal state promptly. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) vm.onResume() } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } UpgradeScreen( state = state, - restoreState = restoreState, onNavigateUp = { vm.navUp() }, - onSubscription = { vm.onGoSubscription() }, - onSubscriptionTrial = { vm.onGoSubscriptionTrial() }, - onIap = { vm.onGoIap() }, + onSubscription = { activity?.let { vm.onGoSubscription(it) } }, + onSubscriptionTrial = { activity?.let { vm.onGoSubscriptionTrial(it) } }, + onIap = { activity?.let { vm.onGoIap(it) } }, onRestore = { vm.restorePurchase() }, + onManageSubscription = { vm.onManageSubscription() }, + ) + + if (showStillRenewingDialog) { + StillRenewingDialog( + onManage = { + showStillRenewingDialog = false + vm.onManageSubscription() + }, + onDismiss = { showStillRenewingDialog = false }, + ) + } + + if (showCheckFailedDialog) { + CheckFailedDialog(onDismiss = { showCheckFailedDialog = false }) + } + + if (showRestoreFailedDialog) { + RestoreFailedDialog(onDismiss = { showRestoreFailedDialog = false }) + } +} + +@Composable +internal fun StillRenewingDialog( + onManage: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_STILL_RENEWING), + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.upgrade_screen_sub_still_renewing_title)) }, + text = { Text(text = stringResource(R.string.upgrade_screen_sub_still_renewing_message)) }, + confirmButton = { + TextButton( + onClick = onManage, + modifier = Modifier.testTag(UpgradeScreenTags.MANAGE_SUB_BUTTON), + ) { + Text(text = stringResource(R.string.upgrade_screen_manage_subscription_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.general_cancel_action)) + } + }, + ) +} + +@Composable +internal fun CheckFailedDialog( + onDismiss: () -> Unit, +) { + AlertDialog( + modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_CHECK_FAILED), + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.upgrade_screen_sub_check_failed_title)) }, + text = { Text(text = stringResource(R.string.upgrade_screen_sub_check_failed_message)) }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.general_done_action)) + } + }, + ) +} + +@Composable +internal fun RestoreFailedDialog( + onDismiss: () -> Unit, +) { + AlertDialog( + modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_RESTORE_FAILED), + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action)) }, + text = { + Text( + text = listOf( + stringResource(R.string.upgrade_screen_restore_purchase_message), + stringResource(R.string.upgrade_screen_restore_troubleshooting_msg), + stringResource(R.string.upgrade_screen_restore_multiaccount_hint), + stringResource(R.string.upgrade_screen_restore_webinstall_hint), + stringResource(R.string.upgrade_screen_restore_sync_patience_hint), + ).joinToString("\n\n") + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.general_done_action)) + } + }, ) } @@ -114,25 +241,14 @@ private data class Benefit(val icon: ImageVector, val textRes: Int) @Composable fun UpgradeScreen( - state: UpgradeViewModel.Pricing?, + state: UpgradeUiState, onNavigateUp: () -> Unit, onSubscription: () -> Unit, onSubscriptionTrial: () -> Unit, onIap: () -> Unit, onRestore: () -> Unit, - restoreState: UpgradeViewModel.RestoreState = UpgradeViewModel.RestoreState(), + onManageSubscription: () -> Unit, ) { - val benefits = listOf( - Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes), - Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay), - - Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups), - Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets), - Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings), - Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls), - Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support), - ) - Scaffold( containerColor = MaterialTheme.colorScheme.surface, ) { paddingValues -> @@ -173,81 +289,28 @@ fun UpgradeScreen( Spacer(modifier = Modifier.height(24.dp)) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.upgrade_preamble), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp), - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - if (restoreState.showRestoreBanner) { - RestoreBanner( - onRestore = onRestore, - restoreInProgress = restoreState.restoreInProgress, - ) - - Spacer(modifier = Modifier.height(24.dp)) - } - - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(vertical = 4.dp)) { - benefits.forEach { benefit -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(28.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = benefit.icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(16.dp), - ) - } - } - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(benefit.textRes), - style = MaterialTheme.typography.bodyLarge, - ) - } - } + when { + state is UpgradeUiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) } - } - Text( - text = stringResource(R.string.upgrade_benefit_disclaimer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 8.dp), - ) + state is UpgradeUiState.Loaded && state.ownership.ownsAnything -> OwnerContent( + state = state, + onIap = onIap, + onRestore = onRestore, + onManageSubscription = onManageSubscription, + ) - Spacer(modifier = Modifier.height(24.dp)) + state is UpgradeUiState.Loaded && state.grace != null -> GraceContent( + state = state, + onSubscription = onSubscription, + onSubscriptionTrial = onSubscriptionTrial, + onIap = onIap, + onRestore = onRestore, + ) - if (state == null) { - CircularProgressIndicator(modifier = Modifier.padding(16.dp)) - } else { - PricingContent( + state is UpgradeUiState.Loaded -> AcquisitionContent( state = state, - restoreInProgress = restoreState.restoreInProgress, onSubscription = onSubscription, onSubscriptionTrial = onSubscriptionTrial, onIap = onIap, @@ -273,16 +336,399 @@ fun UpgradeScreen( } } +// --- Owner view: status instead of sales pitch --- + +@Composable +private fun OwnerContent( + state: UpgradeUiState.Loaded, + onIap: () -> Unit, + onRestore: () -> Unit, + onManageSubscription: () -> Unit, +) { + val ownership = state.ownership + val subscription = ownership.subscription + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.OWNER_HERO), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.TwoTone.Verified, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.upgrade_screen_owned_hero_title), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + // The permanent purchase wins the headline when both are owned. + text = stringResource( + if (ownership.hasIap) R.string.upgrade_screen_owned_hero_iap_body + else R.string.upgrade_screen_owned_hero_sub_body + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + if (subscription != null) { + Spacer(modifier = Modifier.height(12.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.OWNER_SUB_CARD), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.upgrade_screen_owned_sub_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + // No dates: the client can't know the expiry/renewal date, only the intent. + text = stringResource( + if (subscription.isAutoRenewing) R.string.upgrade_screen_owned_sub_renewing_body + else R.string.upgrade_screen_owned_sub_not_renewing_body + ), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onManageSubscription, + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.MANAGE_SUB_BUTTON), + ) { + Text(text = stringResource(R.string.upgrade_screen_manage_subscription_action)) + } + } + } + } + + if (ownership.hasIap) { + Spacer(modifier = Modifier.height(12.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.OWNER_IAP_CARD), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.upgrade_screen_owned_iap_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.upgrade_screen_owned_iap_body), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + if (ownership.hasIap && subscription?.isAutoRenewing == true) { + Spacer(modifier = Modifier.height(12.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.OWNER_WARNING), + ) { + Text( + text = stringResource(R.string.upgrade_screen_owned_both_renewing_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp), + ) + } + } + + if (subscription != null && !ownership.hasIap) { + Spacer(modifier = Modifier.height(12.dp)) + SwitchOfferCard(state = state, onIap = onIap) + } + + Spacer(modifier = Modifier.height(24.dp)) + RestoreSection( + restoreInProgress = state.restoreInProgress, + verificationInProgress = state.verificationInProgress, + onRestore = onRestore, + ) +} + +// Spinner-prefixed button label shared by all busy-capable buttons on this screen. +@Composable +private fun BusyButtonLabel(busy: Boolean, text: String) { + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(text = text) +} + +@Composable +private fun SwitchOfferCard( + state: UpgradeUiState.Loaded, + onIap: () -> Unit, +) { + val subscription = state.ownership.subscription ?: return + val switchUnlocked = !subscription.isAutoRenewing + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.SWITCH_CARD), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.upgrade_screen_iap_offer_title), + style = MaterialTheme.typography.titleMedium, + ) + if (state.iapPrice != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.upgrade_screen_iap_action_hint, state.iapPrice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + if (switchUnlocked) R.string.upgrade_screen_owned_iap_purchase_note + else R.string.upgrade_screen_owned_iap_locked_note + ), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onIap, + // Deliberately NOT gated on price availability: the pricing query may have failed + // while the purchase would work (the billing flow re-queries details on launch). + // The fail-closed SUBS verification happens on tap, in the ViewModel. + enabled = switchUnlocked && state.settled && !state.verificationInProgress && !state.restoreInProgress, + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.SWITCH_BUTTON), + ) { + BusyButtonLabel( + busy = state.verificationInProgress, + text = stringResource(R.string.upgrade_screen_iap_action), + ) + } + } + } +} + +// --- Grace view: Pro is active but Play can't confirm the purchase right now --- + +@Composable +private fun GraceContent( + state: UpgradeUiState.Loaded, + onSubscription: () -> Unit, + onSubscriptionTrial: () -> Unit, + onIap: () -> Unit, + onRestore: () -> Unit, +) { + val grace = state.grace ?: return + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.GRACE_CARD), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (grace.showDiagnostics) { + Icon( + imageVector = Icons.TwoTone.Verified, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.upgrade_screen_grace_title), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + if (grace.showDiagnostics) R.string.upgrade_screen_grace_body + else R.string.upgrade_screen_grace_body_short + ), + style = MaterialTheme.typography.bodyMedium, + ) + if (grace.showDiagnostics) { + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onRestore, + enabled = !state.restoreInProgress && !state.verificationInProgress, + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON), + ) { + BusyButtonLabel( + busy = state.restoreInProgress, + text = stringResource(R.string.upgrade_screen_restore_purchase_action), + ) + } + } + } + } + + // Quiet phase: no offers, no pitch — a Play hiccup usually resolves itself and showing buy + // buttons to a paying user is confusing. Diagnostics phase: the offers return, so an actually + // expired subscriber can switch to the one-time purchase without waiting out the grace window. + if (grace.showDiagnostics) { + Spacer(modifier = Modifier.height(24.dp)) + PricingContent( + state = state, + onSubscription = onSubscription, + onSubscriptionTrial = onSubscriptionTrial, + onIap = onIap, + onRestore = onRestore, + showRestore = false, + ) + } +} + +// --- Acquisition view: the sales pitch --- + +@Composable +private fun AcquisitionContent( + state: UpgradeUiState.Loaded, + onSubscription: () -> Unit, + onSubscriptionTrial: () -> Unit, + onIap: () -> Unit, + onRestore: () -> Unit, +) { + val benefits = listOf( + Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes), + Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay), + + Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups), + Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets), + Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings), + Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls), + Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support), + ) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.upgrade_preamble), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (state.showRestoreBanner) { + RestoreBanner( + onRestore = onRestore, + restoreInProgress = state.restoreInProgress, + verificationInProgress = state.verificationInProgress, + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.BENEFITS), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + benefits.forEach { benefit -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(28.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = benefit.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(benefit.textRes), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + + Text( + text = stringResource(R.string.upgrade_benefit_disclaimer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + PricingContent( + state = state, + onSubscription = onSubscription, + onSubscriptionTrial = onSubscriptionTrial, + onIap = onIap, + onRestore = onRestore, + ) +} + @Composable private fun RestoreBanner( onRestore: () -> Unit, restoreInProgress: Boolean, + verificationInProgress: Boolean, ) { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.tertiaryContainer, ), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.RESTORE_BANNER), ) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -297,36 +743,66 @@ private fun RestoreBanner( Spacer(modifier = Modifier.height(12.dp)) Button( onClick = onRestore, - enabled = !restoreInProgress, + enabled = !restoreInProgress && !verificationInProgress, modifier = Modifier.fillMaxWidth(), ) { - if (restoreInProgress) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action)) + BusyButtonLabel( + busy = restoreInProgress, + text = stringResource(R.string.upgrade_screen_restore_purchase_action), + ) } } } } @Composable -private fun PricingContent( - state: UpgradeViewModel.Pricing, +private fun RestoreSection( restoreInProgress: Boolean, + verificationInProgress: Boolean, + onRestore: () -> Unit, +) { + Text( + text = stringResource(R.string.upgrade_screen_restore_status_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.upgrade_screen_restore_status_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onRestore, + enabled = !restoreInProgress && !verificationInProgress, + modifier = Modifier + .fillMaxWidth() + .testTag(UpgradeScreenTags.RESTORE_BUTTON), + ) { + BusyButtonLabel( + busy = restoreInProgress, + text = stringResource(R.string.upgrade_screen_restore_purchase_action), + ) + } +} + +@Composable +private fun PricingContent( + state: UpgradeUiState.Loaded, onSubscription: () -> Unit, onSubscriptionTrial: () -> Unit, onIap: () -> Unit, onRestore: () -> Unit, + showRestore: Boolean = true, ) { // Subscription button (primary) if (state.subAvailable) { - val subscriptionAction = if (state.hasTrialOffer) onSubscriptionTrial else onSubscription + val subscriptionAction = + if (state.subscriptionAction == SubscriptionAction.TRIAL) onSubscriptionTrial else onSubscription - val subscriptionLabel = if (state.hasTrialOffer) { + val subscriptionLabel = if (state.subscriptionAction == SubscriptionAction.TRIAL) { stringResource(R.string.upgrade_screen_subscription_trial_action) } else { stringResource(R.string.upgrade_screen_subscription_action) @@ -334,10 +810,11 @@ private fun PricingContent( Button( onClick = subscriptionAction, - enabled = !restoreInProgress, + enabled = state.subscriptionEnabled, modifier = Modifier .fillMaxWidth() - .height(52.dp), + .height(52.dp) + .testTag(UpgradeScreenTags.SUB_BUTTON), shape = RoundedCornerShape(12.dp), ) { Icon( @@ -352,9 +829,9 @@ private fun PricingContent( ) } - if (state.subPrice != null) { + if (state.subscriptionPrice != null) { Text( - text = stringResource(R.string.upgrade_screen_subscription_action_hint, state.subPrice), + text = stringResource(R.string.upgrade_screen_subscription_action_hint, state.subscriptionPrice), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -368,10 +845,11 @@ private fun PricingContent( if (state.iapAvailable) { FilledTonalButton( onClick = onIap, - enabled = !restoreInProgress, + enabled = state.iapEnabled, modifier = Modifier .fillMaxWidth() - .height(52.dp), + .height(52.dp) + .testTag(UpgradeScreenTags.IAP_BUTTON), shape = RoundedCornerShape(12.dp), ) { Text( @@ -394,10 +872,11 @@ private fun PricingContent( if (!state.subAvailable && !state.iapAvailable) { Button( onClick = onIap, - enabled = !restoreInProgress, + enabled = state.iapEnabled, modifier = Modifier .fillMaxWidth() - .height(52.dp), + .height(52.dp) + .testTag(UpgradeScreenTags.IAP_BUTTON), shape = RoundedCornerShape(12.dp), ) { Icon( @@ -421,7 +900,7 @@ private fun PricingContent( // Don't promise a free trial when Play didn't return the trial offer (e.g. returning // subscribers) — the subscribe button already falls back accordingly. text = stringResource( - if (state.hasTrialOffer) R.string.upgrade_screen_options_description + if (state.subscriptionAction == SubscriptionAction.TRIAL) R.string.upgrade_screen_options_description else R.string.upgrade_screen_options_description_no_trial ), style = MaterialTheme.typography.bodySmall, @@ -429,58 +908,54 @@ private fun PricingContent( textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(16.dp)) + if (showRestore) { + Spacer(modifier = Modifier.height(16.dp)) - OutlinedButton( - onClick = onRestore, - enabled = !restoreInProgress, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(12.dp), - ) { - if (restoreInProgress) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, + OutlinedButton( + onClick = onRestore, + enabled = !state.restoreInProgress && !state.verificationInProgress, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .testTag(UpgradeScreenTags.RESTORE_BUTTON), + shape = RoundedCornerShape(12.dp), + ) { + BusyButtonLabel( + busy = state.restoreInProgress, + text = stringResource(R.string.upgrade_screen_restore_purchase_action), ) - Spacer(modifier = Modifier.width(8.dp)) } - Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action)) } } +// --- Previews --- + +private fun previewLoaded( + ownership: Ownership = Ownership(), + grace: GraceHint? = null, + showRestoreBanner: Boolean = false, +) = UpgradeUiState.Loaded( + subscriptionAction = SubscriptionAction.TRIAL, + subscriptionEnabled = true, + subscriptionPrice = "€3.49", + iapEnabled = true, + iapPrice = "€6.49", + ownership = ownership, + grace = grace, + showRestoreBanner = showRestoreBanner, +) + @Preview2 @Composable private fun UpgradeScreenPreview() = PreviewWrapper { UpgradeScreen( - state = UpgradeViewModel.Pricing( - subPrice = "€3.49", - iapPrice = "€6.49", - hasTrialOffer = true, - ), - onNavigateUp = {}, - onSubscription = {}, - onSubscriptionTrial = {}, - onIap = {}, - onRestore = {}, - ) -} - -@Preview2 -@Composable -private fun UpgradeScreenNoTrialPreview() = PreviewWrapper { - UpgradeScreen( - state = UpgradeViewModel.Pricing( - subPrice = "€3.49", - iapPrice = "€6.49", - hasTrialOffer = false, - ), + state = previewLoaded(), onNavigateUp = {}, onSubscription = {}, onSubscriptionTrial = {}, onIap = {}, onRestore = {}, + onManageSubscription = {}, ) } @@ -488,16 +963,105 @@ private fun UpgradeScreenNoTrialPreview() = PreviewWrapper { @Composable private fun UpgradeScreenReturningBuyerPreview() = PreviewWrapper { UpgradeScreen( - state = UpgradeViewModel.Pricing( - subPrice = "€3.49", - iapPrice = "€6.49", - hasTrialOffer = true, - ), - restoreState = UpgradeViewModel.RestoreState(showRestoreBanner = true), + state = previewLoaded(showRestoreBanner = true), onNavigateUp = {}, onSubscription = {}, onSubscriptionTrial = {}, onIap = {}, onRestore = {}, + onManageSubscription = {}, + ) +} + +@Preview2 +@Composable +private fun UpgradeScreenOwnerSubRenewingPreview() = PreviewWrapper { + UpgradeScreen( + state = previewLoaded( + ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true)), + ), + onNavigateUp = {}, + onSubscription = {}, + onSubscriptionTrial = {}, + onIap = {}, + onRestore = {}, + onManageSubscription = {}, + ) +} + +@Preview2 +@Composable +private fun UpgradeScreenOwnerSubNotRenewingPreview() = PreviewWrapper { + UpgradeScreen( + state = previewLoaded( + ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)), + ), + onNavigateUp = {}, + onSubscription = {}, + onSubscriptionTrial = {}, + onIap = {}, + onRestore = {}, + onManageSubscription = {}, + ) +} + +@Preview2 +@Composable +private fun UpgradeScreenOwnerIapPreview() = PreviewWrapper { + UpgradeScreen( + state = previewLoaded(ownership = Ownership(hasIap = true)), + onNavigateUp = {}, + onSubscription = {}, + onSubscriptionTrial = {}, + onIap = {}, + onRestore = {}, + onManageSubscription = {}, + ) +} + +@Preview2 +@Composable +private fun UpgradeScreenOwnerBothRenewingPreview() = PreviewWrapper { + UpgradeScreen( + state = previewLoaded( + ownership = Ownership( + hasIap = true, + subscription = SubscriptionOwnership(isAutoRenewing = true), + ), + ), + onNavigateUp = {}, + onSubscription = {}, + onSubscriptionTrial = {}, + onIap = {}, + onRestore = {}, + onManageSubscription = {}, + ) +} + +@Preview2 +@Composable +private fun UpgradeScreenGraceQuietPreview() = PreviewWrapper { + UpgradeScreen( + state = previewLoaded(grace = GraceHint(showDiagnostics = false)), + onNavigateUp = {}, + onSubscription = {}, + onSubscriptionTrial = {}, + onIap = {}, + onRestore = {}, + onManageSubscription = {}, + ) +} + +@Preview2 +@Composable +private fun UpgradeScreenGraceDiagnosticsPreview() = PreviewWrapper { + UpgradeScreen( + state = previewLoaded(grace = GraceHint(showDiagnostics = true)), + onNavigateUp = {}, + onSubscription = {}, + onSubscriptionTrial = {}, + onIap = {}, + onRestore = {}, + onManageSubscription = {}, ) } diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeUiState.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeUiState.kt new file mode 100644 index 00000000..5268b566 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeUiState.kt @@ -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, + ) +} diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt index beeb9ace..f92a243f 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt @@ -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() - val billingEvents = SingleEventFlow() private val restoring = MutableStateFlow(false) - val restoreState: StateFlow = 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(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 = 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 = 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 = 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") } } diff --git a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt index 36df0f58..a547d392 100644 --- a/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt +++ b/app/src/main/java/eu/darken/capod/common/navigation/Nav.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt index 24f191cb..2989535a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt @@ -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()) } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index b7fc559a..2028f576 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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() { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 47bcc0e2..4af107fd 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -334,7 +334,7 @@ class OverviewViewModel @Inject constructor( fun onUpgrade() { log(TAG, INFO) { "onUpgrade()" } - navTo(Nav.Main.Upgrade) + navTo(Nav.Main.Upgrade()) } fun toggleUnmatchedDevices() { diff --git a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt index b54b8a61..84f07d5e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModel.kt @@ -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. diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt index 198f065a..cdcc762e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsScreen.kt @@ -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 = {}, diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsViewModel.kt index b97777ad..62ce4caf 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsViewModel.kt @@ -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) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt index 4fe83be6..6ee074f8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt @@ -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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90c100b0..b9e89277 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,8 +44,35 @@ If you\'ve recently purchased, it may take a moment for Google Play to sync. Try again in a few minutes if your purchase doesn\'t appear. Make sure you\'re signed in with the same Google account used for the purchase. + Multiple accounts can confuse Google Play. Installing the app through the Google Play website forces the association with a specific account. Already bought Pro? It looks like you upgraded to Pro on this device before. Restore your purchase to unlock it again. + Purchase restored. + Status looks wrong? + If you own an upgrade that isn\'t shown here, restore your purchase. + You have CAPod Pro! + Unlocked by your subscription. Thank you for supporting CAPod! + Unlocked by your one-time purchase. Thank you for supporting CAPod! + Yearly subscription + Your subscription is active and set to renew. Billing is managed by Google Play. + Your subscription is active, but not set to renew. Pro stays available until the current period ends. + One-time purchase + Yours forever. No renewals, no expiry. + You own the one-time purchase, but your subscription is still set to renew. Cancel it in Google Play to avoid paying twice. + Manage subscription + Switch to the one-time purchase + Available once your subscription is no longer set to renew. Cancel it in Google Play to switch. + This is a separate purchase — it does not cancel or refund your subscription. Use the same Google account. + Subscription still active + 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. + Subscription check failed + Couldn\'t check your subscription status with Google Play. To avoid a double purchase, try again in a moment. + Confirming your purchase + Google Play hasn\'t confirmed your purchase yet. Pro is still active — no action needed. + 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. + Supporter since %s + Thank you for supporting CAPod\'s development! + Open sponsor page Extra notification Shows an extra notification when a device is connected. This lets you hide the permanent \"No devices\" notification by disabling the \"Device status\" channel. @@ -137,6 +164,10 @@ Debug Settings General tweaks that affect the whole app. + CAPod Pro + Your upgrade and purchase status. + Sponsor CAPod + Your supporter status. Hide unmatched devices Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods. diff --git a/app/src/testFoss/java/eu/darken/capod/upgrade/ui/UpgradeScreenFossComposeTest.kt b/app/src/testFoss/java/eu/darken/capod/upgrade/ui/UpgradeScreenFossComposeTest.kt new file mode 100644 index 00000000..7555386f --- /dev/null +++ b/app/src/testFoss/java/eu/darken/capod/upgrade/ui/UpgradeScreenFossComposeTest.kt @@ -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() + .getString(eu.darken.capod.R.string.upgrade_foss_sponsor_again_action) + composeRule.onNode(androidx.compose.ui.test.hasText(label)).performScrollTo().performClick() + + sponsorTapped shouldBe true + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt index 1381374f..983d7c07 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt @@ -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 - private lateinit var freshDataFlow: MutableSharedFlow + private lateinit var freshDataFlow: MutableSharedFlow + private lateinit var refreshFailuresFlow: MutableSharedFlow 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, 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 { + repo.launchBillingFlow(mockk(), 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>(relaxed = true) { - every { flow } returns flowOf(0L) - } - val lastProSkuMock = mockk>(relaxed = true) { - every { flow } returns flowOf("") - } - val cache = mockk { - every { lastProStateAt } returns lastProAtMock - every { lastProStateSku } returns lastProSkuMock + val cache = mockk(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(replay = 1) - replayingFresh.tryEmit(BillingData(purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)))) + val replayingFresh = MutableSharedFlow(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>(relaxed = true) { - every { flow } returns flowOf(0L) - } - val lastProSkuMock = mockk>(relaxed = true) { - every { flow } returns flowOf("") - } - val cache = mockk { - every { lastProStateAt } returns lastProAtMock - every { lastProStateSku } returns lastProSkuMock + val proFresh = FreshBillingData(proData, isFullSnapshot = true) + val cache = mockk(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() + 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() + } } diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt index 0c7fd043..0697ce67 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt @@ -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> = MutableStateFlow(emptySet()), + var generation: Long = 0L, + ) { + val client = mockk(relaxed = true) + val freshObservations = MutableSharedFlow(replay = 1, extraBufferCapacity = 16) + val freshFailures = MutableSharedFlow(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) = 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()) } 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()) } 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()) } 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()) } returnsMany listOf( + harness.errorResult(), + harness.okResult(emptyList()), + ) + + shouldThrow { + 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()) } 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()) } 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()) } 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()) } 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()) } 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()) } returns + harness.errorResult() + + shouldThrow { + harness.connection.querySubscriptions() + } + + harness.freshFailures.first() shouldBe Unit } @Test diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt index 2aa6d6bf..0e1cb6ec 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt @@ -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 { every { purchases } returns emptyFlow() - coEvery { refreshPurchases() } returns emptyList() + every { freshFailures } returns emptyFlow() + coEvery { refreshPurchases() } returns FreshPurchases(emptyList(), isFullSnapshot = true) } val provider = mockk { 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 { + every { purchases } returns flowOf(listOf(stale), listOf(stale), listOf(stale)) + coEvery { acknowledgePurchase(any()) } returns Unit + } + val provider = mockk { + every { connection } returns flowOf(clientConnection) + } + val foregroundState = mockk { + 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 { + every { purchases } returns flowOf(listOf(unacked)) + coEvery { acknowledgePurchase(any()) } coAnswers { + ackAttempts++ + if (ackAttempts == 1) { + throw BillingResultException(mockBillingResult(BillingClient.BillingResponseCode.ERROR)) + } + } + } + val provider = mockk { + every { connection } returns flowOf(clientConnection) + } + val foregroundState = mockk { + 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 { + every { purchases } returns emptyFlow() + every { freshFailures } returns emptyFlow() + coEvery { refreshPurchases() } coAnswers { awaitCancellation() } + } + val provider = mockk { + every { connection } returns flowOf(clientConnection) + } + val foreground = MutableStateFlow(false) + val foregroundState = mockk { + every { isForeground } returns foreground + } + + try { + val repo = BillingDataRepo(provider, testScope, foregroundState, TestTimeSource()) + val failures = mutableListOf() + 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 { + every { purchases } returns emptyFlow() + every { freshFailures } returns emptyFlow() + coEvery { querySubscriptions() } throws BillingResultException( + mockBillingResult(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) + ) + } + val provider = mockk { + every { connection } returns flowOf(clientConnection) + } + val foregroundState = mockk { + 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 { + repo.querySubscriptions() + } + } finally { + testScope.cancel() + } + } } diff --git a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt new file mode 100644 index 00000000..09ca83e4 --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeScreenComposeTest.kt @@ -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() + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt index 9b5a1154..355d5539 100644 --- a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt @@ -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(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(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() - val errors = mutableListOf() - val eventJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.events.collect { events.add(it) } } - val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } } - + 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() + 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() - 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()) - 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() - val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } } + val event = async { vm.events.first() } + vm.onGoIap(mockk()) 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() - val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } } + vm.onGoIap(mockk()) 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()) + 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()) + 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()) + vm.onGoIap(mockk()) + vm.onGoSubscription(mockk()) + 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()) + 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()) + 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() val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } } - vm.launchBillingIap(mockk()) + vm.onGoIap(mockk()) 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()) + vm.onGoSubscription(mockk()) 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() + 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() + 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() + 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() + 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() + 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 + } }