From 8c1b57a47c63aafaac8e47dfa85b3ceddf0e90cf Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 29 Jul 2026 12:38:08 +0200 Subject: [PATCH] refactor(upgrade): Adopt canonical entitlement interface and gates UpgradeRepo gains the canonical shape: settledness rides each Info emission, plus storeSite/upgradeSite/betaSite and a suspend refresh(). getSponsorUrl() is replaced by upgradeSite (FOSS only, GPlay keeps the heart icon hidden). UpgradeRepoExtensions is the canonical file with isPro/isProSettled/isProForUi. UpgradeRepoGplay folds its parallel isSettled flow into Info.isSettled (behaviour preserving) and implements refresh() as a bounded, unthrottled call to the existing billing refresh. UpgradeControlFoss is settled from its first emission and no-ops refresh(). Interactive gates move to isProForUi so a paying user isn't bounced to the upgrade screen during the GPlay cold-start race: the device-settings and press-controls pro gates, the theme setters, and the widget confirm action, which now goes through a sealed ConfirmOutcome so the activity can only return RESULT_OK for an entitled, valid configuration. Presentation paths that can't reach a suspending gate (general settings theme items, overview device limit) render the upgrade branch only when the entitlement is hard-locked: settled, error-free and not pro. --- .../common/upgrade/core/UpgradeControlFoss.kt | 24 +- .../common/upgrade/core/UpgradeRepoGplay.kt | 78 +++-- .../capod/upgrade/ui/UpgradeViewModel.kt | 3 +- .../compose/preview/MockPodDataProvider.kt | 2 + .../capod/common/upgrade/UpgradeRepo.kt | 18 +- .../common/upgrade/UpgradeRepoExtensions.kt | 93 +++++- .../devicesettings/DeviceSettingsViewModel.kt | 18 +- .../main/ui/overview/OverviewViewModel.kt | 13 +- .../presscontrols/PressControlsViewModel.kt | 4 +- .../main/ui/settings/SettingsViewModel.kt | 7 +- .../settings/general/GeneralSettingsScreen.kt | 10 +- .../general/GeneralSettingsViewModel.kt | 28 +- .../ui/widget/WidgetConfigurationActivity.kt | 31 +- .../ui/widget/WidgetConfigurationViewModel.kt | 37 +++ .../upgrade/UpgradeRepoExtensionsTest.kt | 268 ++++++++++++++++++ .../DeviceSettingsViewModelTest.kt | 4 + .../main/ui/overview/OverviewViewModelTest.kt | 69 ++++- .../PressControlsViewModelTest.kt | 4 + .../general/GeneralSettingsViewModelTest.kt | 203 +++++++++++++ .../main/ui/tile/AncTileStateStoreTest.kt | 1 + .../WidgetConfigurationViewModelTest.kt | 168 +++++++++++ .../capod/upgrade/ui/UpgradeViewModelTest.kt | 39 +-- 22 files changed, 1047 insertions(+), 75 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensionsTest.kt create mode 100644 app/src/test/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModelTest.kt create mode 100644 app/src/test/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModelTest.kt diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt index 7e4f57d2..3dfc8bb7 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt @@ -1,5 +1,7 @@ package eu.darken.capod.common.upgrade.core +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.upgrade.UpgradeRepo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -13,6 +15,10 @@ class UpgradeControlFoss @Inject constructor( private val fossCache: FossCache, ) : UpgradeRepo { + override val storeSite: String = STORE_SITE + override val upgradeSite: String = UPGRADE_SITE + override val betaSite: String = BETA_SITE + override val upgradeInfo: Flow = fossCache.upgrade.flow.map { data -> if (data == null) { Info() @@ -32,6 +38,12 @@ class UpgradeControlFoss @Inject constructor( ) } + override suspend fun refresh() { + log(TAG) { "refresh()" } + // The FOSS entitlement is a local cache read that the upgradeInfo flow already observes, + // there is no remote state to reconcile. + } + data class Info( override val isPro: Boolean = false, override val upgradedAt: Instant? = null, @@ -39,8 +51,16 @@ class UpgradeControlFoss @Inject constructor( override val error: Throwable? = null, ) : UpgradeRepo.Info { override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS + + // The FOSS entitlement is a local cache read — authoritative from the first emission, + // there is no billing handshake to wait out. + override val isSettled: Boolean = true } - override fun getSponsorUrl(): String = "https://github.com/sponsors/d4rken" - + companion object { + private const val STORE_SITE = "https://github.com/d4rken-org/capod/releases" + private const val UPGRADE_SITE = "https://github.com/sponsors/d4rken" + private const val BETA_SITE = "https://play.google.com/apps/testing/eu.darken.capod" + private val TAG = logTag("Upgrade", "Foss", "Control") + } } \ No newline at end of file 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 776fce2a..b08359cd 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 @@ -56,6 +56,10 @@ class UpgradeRepoGplay @Inject constructor( private val timeSource: TimeSource, ) : UpgradeRepo { + override val storeSite: String = STORE_SITE + override val upgradeSite: String = UPGRADE_SITE + override val betaSite: String = BETA_SITE + // 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. @@ -157,9 +161,11 @@ class UpgradeRepoGplay @Inject constructor( // Reactive fallback when the upgradeInfo mapping throws (only local DataStore reads can fail here // now — the connection loop retries billing errors itself): keep a recently-Pro user in grace, // otherwise surface the error. Never throws — a second cache failure resolves to the error Info. + // Settled: a local storage failure is a definitive best-knowledge outcome, gates must resolve + // now instead of stalling out a 30s+ retry backoff. private suspend fun graceOrError(error: Throwable): Info = - if (isRecentlyPro()) Info(gracePeriod = true, billingData = null) - else Info(billingData = null, error = error) + if (isRecentlyPro()) Info(gracePeriod = true, billingData = null, isSettled = true) + else Info(billingData = null, error = error, isSettled = true) private fun String.isIapSku(): Boolean = CapodSku.PRO_SKUS.singleOrNull { it.id == this }?.type == Sku.Type.IAP @@ -196,13 +202,24 @@ class UpgradeRepoGplay @Inject constructor( true } + // 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. Combined INTO + // each Info below (UpgradeRepo.Info.isSettled) instead of being exposed as a parallel flow, so + // settledness can never be observed out of step with the ownership data it describes. + private val settledSignal: Flow = billingDataRepo.freshBillingData + .map { true } + .onStart { emit(false) } + .distinctUntilChanged() + override val upgradeInfo: Flow = combine( billingDataRepo.billingData .map { it } .onStart { emit(null) }, graceDeadlineTick, - ) { data, _ -> data } - .map { data -> data.toUpgradeInfo() } + settledSignal, + ) { data, _, settled -> data to settled } + .map { (data, settled) -> data.toUpgradeInfo(settled = settled) } .retryWhen { error, attempt -> // Defensive backstop: toUpgradeInfo() now routes its cache access through the // guarded+bounded isRecentlyPro() and so never throws for a failing/hung DataStore, and @@ -246,18 +263,27 @@ class UpgradeRepoGplay @Inject constructor( true } - // 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() + override suspend fun refresh() { + log(TAG) { "refresh()" } + try { + // Bounded: with unbounded connection retry, an unavailable Play would otherwise keep + // background callers suspended indefinitely. Grace stamping happens via the + // freshBillingData collector, not here. + val fresh = withTimeoutOrNull(REFRESH_TIMEOUT_MS) { billingDataRepo.refresh() } + if (fresh == null) log(TAG, WARN) { "Background refresh timed out" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Background refresh: swallow-and-log so callers aren't affected. The explicit restore + // path uses restorePurchaseNow(), which surfaces errors. + log(TAG, WARN) { "Background refresh failed: ${e.asLog()}" } + } + } + // 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". @@ -276,7 +302,8 @@ class UpgradeRepoGplay @Inject constructor( } catch (e: Exception) { log(TAG, WARN) { "restore: failed to record pro state: ${e.asLog()}" } } - fresh.data.toUpgradeInfo() + // A completed Play round-trip is settled knowledge by definition. + fresh.data.toUpgradeInfo(settled = true) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -287,7 +314,7 @@ class UpgradeRepoGplay @Inject constructor( // the original error" rather than escaping with the probe's exception. if (isRecentlyPro()) { log(TAG, VERBOSE) { "Restore hit an error but we were Pro recently -> grace" } - Info(gracePeriod = true, billingData = null) + Info(gracePeriod = true, billingData = null, isSettled = true) } else { throw e } @@ -303,8 +330,15 @@ class UpgradeRepoGplay @Inject constructor( // local storage is unreadable (mapped-first return, no DataStore access), and a purchase list // containing only products this app doesn't know maps to zero upgrades and correctly falls // through to the grace check instead of masquerading as a confirmed purchase. - private suspend fun BillingData?.toUpgradeInfo(): Info { - val mapped = Info(billingData = this, upgrades = this?.getProSkus() ?: emptyList()) + // + // settled comes from the caller, never from billingData nullness: the grace branch returns an + // Info with billingData = null that may well be settled (built from a real empty snapshot). + private suspend fun BillingData?.toUpgradeInfo(settled: Boolean): Info { + val mapped = Info( + billingData = this, + upgrades = this?.getProSkus() ?: emptyList(), + isSettled = settled, + ) if (mapped.upgrades.isNotEmpty()) return mapped // No confirmed purchase (incl. the null pre-data placeholder the combine seeds): fall back to @@ -315,7 +349,7 @@ class UpgradeRepoGplay @Inject constructor( // purchase that arrives behind it in the sequential map. return if (isRecentlyPro()) { log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" } - Info(gracePeriod = true, billingData = null) + Info(gracePeriod = true, billingData = null, isSettled = settled) } else { mapped } @@ -357,6 +391,9 @@ class UpgradeRepoGplay @Inject constructor( private val billingData: BillingData?, val upgrades: Collection = emptyList(), override val error: Throwable? = null, + // Default false is the fail-safe direction: a forgotten stamp shows up as "never settles" + // (loud), never as a settled pre-reconciliation flash. + override val isSettled: Boolean = false, ) : UpgradeRepo.Info { override val type: UpgradeRepo.Type @@ -440,6 +477,13 @@ class UpgradeRepoGplay @Inject constructor( private const val RESTORE_ON_OWNED_TIMEOUT_MS = 15_000L + // Bounds "connection wait + Play round-trip" for the background refresh. + private const val REFRESH_TIMEOUT_MS = 30_000L + + private const val STORE_SITE = "https://play.google.com/store/apps/details?id=eu.darken.capod" + private const val UPGRADE_SITE = "https://play.google.com/store/apps/details?id=eu.darken.capod" + private const val BETA_SITE = "https://play.google.com/apps/testing/eu.darken.capod" + val TAG: String = logTag("Upgrade", "Gplay", "Control") } } 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 4ddda1a5..c5dab3b2 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 @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn @@ -82,7 +83,7 @@ class UpgradeViewModel @Inject constructor( // 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 }, + upgradeRepo.upgradeInfo.map { it.isSettled }.filter { it }, flow { delay(SETTLE_FALLBACK_MS) emit(true) diff --git a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt index 52951186..a0315a7c 100644 --- a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt +++ b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt @@ -372,6 +372,8 @@ private data class MockUpgradeInfo( override val isPro: Boolean, override val upgradedAt: Instant? = null, override val error: Throwable? = null, + // Previews render the definitive state, never the cold-start seed. + override val isSettled: Boolean = true, ) : UpgradeRepo.Info private class MockDualBlePodSnapshot( diff --git a/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepo.kt b/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepo.kt index 2a7ade6f..4bac8aaa 100644 --- a/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepo.kt +++ b/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepo.kt @@ -4,15 +4,29 @@ import kotlinx.coroutines.flow.Flow import java.time.Instant interface UpgradeRepo { + val storeSite: String + val upgradeSite: String + val betaSite: String + val upgradeInfo: Flow - fun getSponsorUrl(): String? = null + suspend fun refresh() interface Info { val type: Type val isPro: Boolean + /** + * Whether this Info reflects a real entitlement lookup (or a definitive can't-reach-Play + * outcome). Settledness rides each emission instead of a parallel flow, so it can never + * be observed out of step with the ownership data it describes. On GPlay the seed emitted + * before the first billing result after process start is unsettled (and reports non-Pro + * even for paying users); FOSS reads a local cache and is settled from the first + * emission. See `isProForUi` for the gate that uses this. + */ + val isSettled: Boolean + val upgradedAt: Instant? val error: Throwable? @@ -22,4 +36,4 @@ interface UpgradeRepo { GPLAY, FOSS } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensions.kt b/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensions.kt index c75286ca..29daafa0 100644 --- a/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensions.kt +++ b/app/src/main/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensions.kt @@ -1,6 +1,97 @@ package eu.darken.capod.common.upgrade +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.asLog +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +private val TAG = logTag("Upgrade", "Repo", "Extensions") -suspend fun UpgradeRepo.isPro(): Boolean = upgradeInfo.first().isPro \ No newline at end of file +suspend fun UpgradeRepo.isPro(): Boolean = upgradeInfo.first().isPro + +/** + * Pro check for backend safety-net gates (e.g. a tool's task-submit boundary). + * + * Deliberately generous — it prefers to fail open rather than block a paying user: + * - Returns `true` immediately if we already know the user is Pro (active purchase or grace period). + * - Otherwise nudges a billing [UpgradeRepo.refresh] and waits for a Pro state to appear. This + * rescues a genuine Pro user from the GPlay cold-start race, where [UpgradeRepo.upgradeInfo] + * reports non-Pro until the billing connection settles. [timeout] is the budget for the WHOLE + * reconciliation (refresh round-trip plus wait), so a Play call that hangs can't stretch the gate + * past it. + * - Only denies when the state after that window is settled, error-free and still reports no + * purchase (the realistic "free user reached a Pro-only path via a UI mistake" case, where billing + * is already connected). + * - Fails open (returns `true`) when the window elapses without billing settling, when the settled + * state carries an error, and on any exception — a billing hiccup must never block a paying user. + * + * The FOSS flavor reads from a synchronous cache, so the fast path resolves immediately there. + */ +suspend fun UpgradeRepo.isProSettled(timeout: Duration = 5.seconds): Boolean = try { + if (upgradeInfo.first().isPro) { + true + } else { + // One budget for the WHOLE reconciliation: refresh + wait. Waiting only for isPro (not + // isSettled) is deliberate — replayed emissions are already settled, so a settled-predicate + // would return the stale pre-refresh state instead of the refresh outcome. + val proAppeared = withTimeoutOrNull(timeout) { + refresh() + upgradeInfo.first { it.isPro } + true + } == true + if (proAppeared) { + true + } else { + // Full window elapsed after the refresh started — the pipeline has caught up by now. + val current = upgradeInfo.first() + when { + current.isPro -> true // pro landed as the window closed: never deny a known pro + current.error != null -> true // settled error state: fail open + current.isSettled -> false // settled and still no purchase: the documented deny + else -> true // never settled within the budget: documented fail-open + } + } + } +} catch (e: CancellationException) { + // A cancelled caller must not continue down the Pro path via the fail-open below. + throw e +} catch (e: Exception) { + log(TAG, WARN) { "isProSettled() failed, failing open (allowing): ${e.asLog()}" } + true +} + +/** + * Pro check for UI gates: tap handlers that route between a gated action and the upgrade screen. + * + * Resolves immediately in the common cases — already Pro, or billing settled and not Pro — so the + * upgrade screen stays snappy for free users (unlike [isProSettled], whose non-Pro path always + * waits out its timeout). Only while billing is still connecting (GPlay cold start or reconnect, + * where [UpgradeRepo.upgradeInfo] reports non-Pro even for paying users) does it wait, up to + * [timeout], for the first settled Info — so a Pro user isn't bounced to the upgrade screen by + * the handshake race. Every decision reads isPro and isSettled off the SAME Info emission, so a + * settle signal can never pair with stale ownership. On timeout or error it falls back to the + * current state: the UI route is recoverable, and the backend [isProSettled] gate remains the + * enforcement safety net. + */ +suspend fun UpgradeRepo.isProForUi(timeout: Duration = 3.seconds): Boolean = try { + val current = upgradeInfo.first() + when { + current.isPro -> true + current.isSettled -> false + else -> { + val settled = withTimeoutOrNull(timeout) { upgradeInfo.first { it.isSettled } } + (settled ?: upgradeInfo.first()).isPro + } + } +} catch (e: CancellationException) { + // A cancelled caller must not continue down the Pro path via the fail-open below. + throw e +} catch (e: Exception) { + log(TAG, WARN) { "isProForUi() failed, failing open (allowing): ${e.asLog()}" } + true +} 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 2028f576..c0f759ce 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 @@ -17,7 +17,7 @@ import eu.darken.capod.common.flow.SingleEventFlow import eu.darken.capod.common.navigation.Nav import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.upgrade.UpgradeRepo -import eu.darken.capod.common.upgrade.isPro +import eu.darken.capod.common.upgrade.isProForUi import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.MonitorModeResolver @@ -280,7 +280,7 @@ class DeviceSettingsViewModel @Inject constructor( } private fun sendProGated(command: AapCommand) = launch { - if (upgradeRepo.isPro()) { + if (upgradeRepo.isProForUi()) { sendInternal(command) } else { navTo(Nav.Main.Upgrade()) @@ -310,7 +310,7 @@ class DeviceSettingsViewModel @Inject constructor( fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask)) fun setAllowOffOption(enabled: Boolean) = launch { - if (!upgradeRepo.isPro()) { + if (!upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } @@ -333,7 +333,7 @@ class DeviceSettingsViewModel @Inject constructor( fun setSleepDetection(enabled: Boolean) = launch { log(TAG, INFO) { "setSleepDetection($enabled)" } - if (enabled && !upgradeRepo.isPro()) { + if (enabled && !upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } @@ -385,7 +385,7 @@ class DeviceSettingsViewModel @Inject constructor( transform: (AppleDeviceProfile) -> AppleDeviceProfile, ) = launch { // Disabling never requires pro; enabling does. - if (enabled && !upgradeRepo.isPro()) { + if (enabled && !upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } @@ -401,7 +401,7 @@ class DeviceSettingsViewModel @Inject constructor( fun setAutoPlay(enabled: Boolean) = launch { log(TAG, INFO) { "setAutoPlay($enabled)" } - if (enabled && !upgradeRepo.isPro()) { + if (enabled && !upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } @@ -411,7 +411,7 @@ class DeviceSettingsViewModel @Inject constructor( fun setAutoPause(enabled: Boolean) = launch { log(TAG, INFO) { "setAutoPause($enabled)" } - if (enabled && !upgradeRepo.isPro()) { + if (enabled && !upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } @@ -421,7 +421,7 @@ class DeviceSettingsViewModel @Inject constructor( fun setStartMusicOnWear(enabled: Boolean) = launch { log(TAG, INFO) { "setStartMusicOnWear($enabled)" } - if (enabled && !upgradeRepo.isPro()) { + if (enabled && !upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } @@ -490,7 +490,7 @@ class DeviceSettingsViewModel @Inject constructor( log(TAG, INFO) { "setConversationAction($action)" } // 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()) { + if (action != ConversationAction.NOTHING && !upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } 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 4af107fd..d8fd7067 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 @@ -262,8 +262,19 @@ class OverviewViewModel @Inject constructor( ) } + /** + * Truncate to the free limit ONLY when the entitlement is hard-locked: billing settled, + * error-free and reporting no purchase. During the GPlay cold-start seed (unsettled, and + * non-Pro even for paying users) or an error state the full list stays visible — a paying + * user's devices must not disappear and reappear on every launch. + * + * Predicate inlined on purpose: `UpgradeRepoExtensions` stays byte-identical to canonical. + */ val visibleProfiledDevices: List - get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT) + get() { + val hardLocked = upgradeInfo.error == null && upgradeInfo.isSettled && !upgradeInfo.isPro + return if (hardLocked) profiledDevices.take(FREE_DEVICE_LIMIT) else profiledDevices + } val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size val unmatchedDevices: List get() = devices.filter { it.profileId == null } 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 84f07d5e..17ec3dda 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 @@ -10,7 +10,7 @@ import eu.darken.capod.common.flow.SingleEventFlow import eu.darken.capod.common.navigation.Nav import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.upgrade.UpgradeRepo -import eu.darken.capod.common.upgrade.isPro +import eu.darken.capod.common.upgrade.isProForUi import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.aap.AapConnectionManager @@ -143,7 +143,7 @@ class PressControlsViewModel @Inject constructor( if (action == current) return@launch // 2. Free-clear allowance — always allow clearing to None. // 3. Otherwise Pro is required. - if (action != StemAction.None && !upgradeRepo.isPro()) { + if (action != StemAction.None && !upgradeRepo.isProForUi()) { navTo(Nav.Main.Upgrade()) return@launch } 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 7e8fa71f..ec905956 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 @@ -26,7 +26,12 @@ class SettingsViewModel @Inject constructor( .map { State( isPro = it.isPro, - sponsorUrl = upgradeRepo.getSponsorUrl(), + // Only the FOSS flavor has a sponsor flow (its upgrade site IS the sponsor page), + // on GPlay the entitlement is bought in the app and the heart icon stays hidden. + sponsorUrl = when (it.type) { + UpgradeRepo.Type.FOSS -> upgradeRepo.upgradeSite + UpgradeRepo.Type.GPLAY -> null + }, ) } .asLiveState() diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt index 7fed44f2..4150c37e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsScreen.kt @@ -114,7 +114,7 @@ fun GeneralSettingsScreen( SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label)) } item { - if (state.isPro) { + if (!state.isUpgradeLocked) { SettingsListPreferenceItem( icon = Icons.TwoTone.DarkMode, title = stringResource(R.string.ui_theme_mode_label), @@ -134,7 +134,7 @@ fun GeneralSettingsScreen( } } item { - if (state.isPro) { + if (!state.isUpgradeLocked) { SettingsListPreferenceItem( icon = Icons.TwoTone.Contrast, title = stringResource(R.string.ui_theme_style_label), @@ -163,10 +163,10 @@ fun GeneralSettingsScreen( }, icon = Icons.TwoTone.Palette, onClick = { - if (!state.isPro) onUpgrade() else showColorDialog = true + if (state.isUpgradeLocked) onUpgrade() else showColorDialog = true }, enabled = !isMaterialYouActive, - requiresUpgrade = !state.isPro && !isMaterialYouActive, + requiresUpgrade = state.isUpgradeLocked && !isMaterialYouActive, ) } item { @@ -283,7 +283,7 @@ fun GeneralSettingsScreen( } private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State( - isPro = isPro, + isUpgradeLocked = !isPro, showConnectedNotification = true, keepNotificationAfterDisconnect = false, isOffloadedFilteringDisabled = false, 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 6ee074f8..562e5863 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 @@ -12,10 +12,10 @@ import eu.darken.capod.common.theming.ThemeState import eu.darken.capod.common.theming.ThemeStyle import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.common.upgrade.isProForUi import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.themeState import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject import eu.darken.capod.common.datastore.valueBlocking @@ -28,7 +28,13 @@ class GeneralSettingsViewModel @Inject constructor( ) : ViewModel4(dispatcherProvider) { data class State( - val isPro: Boolean, + /** + * True only when billing settled without an error and reports no entitlement — the + * presentation mirror of what [isProForUi] would deny. While billing is still connecting + * (GPlay cold-start seed) a paying user keeps the real controls instead of being shown the + * upgrade branch; the setters re-check via [isProForUi] before writing. + */ + val isUpgradeLocked: Boolean, val showConnectedNotification: Boolean, val keepNotificationAfterDisconnect: Boolean, val isOffloadedFilteringDisabled: Boolean, @@ -38,7 +44,11 @@ class GeneralSettingsViewModel @Inject constructor( val themeState: ThemeState, ) - private val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveState() + // Hard-locked = settled, error-free and no entitlement. Anything else (unsettled seed, error) + // keeps the pro presentation, so the cold-start race can't route a paying user to upgrade. + private val isUpgradeLocked = upgradeRepo.upgradeInfo + .map { it.error == null && it.isSettled && !it.isPro } + .asLiveState() val state = combine( combine( @@ -57,11 +67,11 @@ class GeneralSettingsViewModel @Inject constructor( arrayOf(filtering as Any, batching as Any, indirect as Any) }, generalSettings.themeState, - isPro, + isUpgradeLocked, generalSettings.hideUnmatchedDevices.flow, - ) { general, compat, themeState, isPro, hideUnmatched -> + ) { general, compat, themeState, upgradeLocked, hideUnmatched -> State( - isPro = isPro, + isUpgradeLocked = upgradeLocked, showConnectedNotification = general[0] as Boolean, keepNotificationAfterDisconnect = general[1] as Boolean, isOffloadedFilteringDisabled = compat[0] as Boolean, @@ -104,7 +114,7 @@ class GeneralSettingsViewModel @Inject constructor( fun setThemeMode(mode: ThemeMode) = launch { log(TAG, INFO) { "setThemeMode($mode)" } - if (isPro.first()) { + if (upgradeRepo.isProForUi()) { generalSettings.themeMode.valueBlocking = mode } else { navTo(Nav.Main.Upgrade()) @@ -113,7 +123,7 @@ class GeneralSettingsViewModel @Inject constructor( fun setThemeStyle(style: ThemeStyle) = launch { log(TAG, INFO) { "setThemeStyle($style)" } - if (isPro.first()) { + if (upgradeRepo.isProForUi()) { generalSettings.themeStyle.valueBlocking = style } else { navTo(Nav.Main.Upgrade()) @@ -122,7 +132,7 @@ class GeneralSettingsViewModel @Inject constructor( fun setThemeColor(color: ThemeColor) = launch { log(TAG, INFO) { "setThemeColor($color)" } - if (isPro.first()) { + if (upgradeRepo.isProForUi()) { generalSettings.themeColor.valueBlocking = color } else { navTo(Nav.Main.Upgrade()) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt index 8bf8ae25..c750d89e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt @@ -104,15 +104,30 @@ class WidgetConfigurationActivity : Activity2() { onSetShowDeviceLabel = { show -> vm.setShowDeviceLabel(show) }, onReset = { vm.resetToDefaults() }, onConfirm = { - if (currentState.isPro) { - confirmSelection(currentState.isAncWidget) - } else { - upgradeLauncher.launch( - Intent(this@WidgetConfigurationActivity, MainActivity::class.java).apply { - putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true) - putExtra(MainActivity.EXTRA_UPGRADE_FOR_RESULT, true) + // The decision is made in the ViewModel (suspending entitlement gate), + // so RESULT_OK is only ever set for an entitled, valid configuration. + lifecycleScope.launch { + when (vm.decideConfirm()) { + WidgetConfigurationViewModel.ConfirmOutcome.Confirmed -> { + confirmSelection(currentState.isAncWidget) } - ) + + WidgetConfigurationViewModel.ConfirmOutcome.UpgradeRequired -> { + upgradeLauncher.launch( + Intent( + this@WidgetConfigurationActivity, + MainActivity::class.java + ).apply { + putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true) + putExtra(MainActivity.EXTRA_UPGRADE_FOR_RESULT, true) + } + ) + } + + WidgetConfigurationViewModel.ConfirmOutcome.Invalid -> { + log(TAG) { "Confirm rejected, staying in config" } + } + } } }, onCancel = { finish() }, diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt index b6dabd07..b4d28e7e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt @@ -13,10 +13,12 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.combine import eu.darken.capod.common.uix.ViewModel2 import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.common.upgrade.isProForUi import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.ProfileId import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -153,6 +155,41 @@ class WidgetConfigurationViewModel @Inject constructor( currentTheme.value = WidgetTheme.DEFAULT } + /** Decision for the confirm action, so the host can't return RESULT_OK without an entitlement. */ + sealed interface ConfirmOutcome { + /** Entitled and the configuration is valid — save it and return RESULT_OK. */ + data object Confirmed : ConfirmOutcome + + /** Valid configuration, but no entitlement — route to the upgrade flow. */ + data object UpgradeRequired : ConfirmOutcome + + /** No widget or no usable profile selected — stay in the configuration. */ + data object Invalid : ConfirmOutcome + } + + /** + * Validity first, entitlement second: an invalid configuration must not send the user shopping. + * The gate is [isProForUi] rather than the state's cached isPro, so a paying user tapping + * confirm during the GPlay cold-start seed waits for the entitlement instead of being bounced + * into the upgrade flow. + */ + suspend fun decideConfirm(): ConfirmOutcome { + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + log(TAG, INFO) { "decideConfirm(): invalid widget ID" } + return ConfirmOutcome.Invalid + } + if (!state.first().canConfirm) { + log(TAG, INFO) { "decideConfirm(): configuration is not confirmable" } + return ConfirmOutcome.Invalid + } + return if (upgradeRepo.isProForUi()) { + ConfirmOutcome.Confirmed + } else { + log(TAG, INFO) { "decideConfirm(): upgrade required" } + ConfirmOutcome.UpgradeRequired + } + } + fun confirmSelection() { if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { log(TAG, INFO) { "confirmSelection: invalid widget ID, skipping save" } diff --git a/app/src/test/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensionsTest.kt b/app/src/test/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensionsTest.kt new file mode 100644 index 00000000..744ae787 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/upgrade/UpgradeRepoExtensionsTest.kt @@ -0,0 +1,268 @@ +package eu.darken.capod.common.upgrade + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.launch + +class UpgradeRepoExtensionsTest : BaseTest() { + + private class FakeInfo( + override val isPro: Boolean, + override val isSettled: Boolean, + override val error: Throwable? = null, + ) : UpgradeRepo.Info { + override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS + override val upgradedAt: Instant? = null + } + + private class FakeRepo( + pro: Boolean, + settled: Boolean, + error: Throwable? = null, + ) : UpgradeRepo { + // Settledness rides the Info itself — a single flow, like the production repos. + val infoFlow = MutableStateFlow(FakeInfo(pro, settled, error)) + var refreshCalls = 0 + + // What a refresh() round-trip does before returning: production ones can hang, publish a + // new Info a dispatcher turn later, or land in an error state. + var onRefresh: suspend FakeRepo.() -> Unit = {} + + override val storeSite: String = "" + override val upgradeSite: String = "" + override val betaSite: String = "" + override val upgradeInfo: Flow = infoFlow + override suspend fun refresh() { + refreshCalls++ + onRefresh() + } + + fun settle(pro: Boolean, error: Throwable? = null) { + infoFlow.value = FakeInfo(pro, isSettled = true, error = error) + } + } + + // region isProSettled (backend gate) + + @Test + fun `a known pro user resolves true without refreshing`() = runTest { + val repo = FakeRepo(pro = true, settled = false) + + repo.isProSettled() shouldBe true + + repo.refreshCalls shouldBe 0 + currentTime shouldBe 0 + } + + @Test + fun `a pro state published after the refresh returned still counts`() = runTest { + // The refresh discovers the purchase, but the shared upgradeInfo pipeline publishes the new + // Info a dispatcher turn later. Waiting on a settled-predicate would have matched the + // replayed pre-refresh emission and denied a paying user. + val repo = FakeRepo(pro = false, settled = true) + repo.onRefresh = { + backgroundScope.launch { settle(pro = true) } + } + + repo.isProSettled() shouldBe true + } + + @Test + fun `a pro state appearing within the window resolves true`() = runTest { + val repo = FakeRepo(pro = false, settled = false) + backgroundScope.launch { + delay(500) + repo.settle(pro = true) + } + + repo.isProSettled() shouldBe true + currentTime shouldBe 500 + } + + @Test + fun `a settled state without a purchase denies after the full window`() = runTest { + // The realistic "free user reached a Pro-only path" case — the only one that denies. + val repo = FakeRepo(pro = false, settled = true) + + repo.isProSettled() shouldBe false + currentTime shouldBe 5_000 + } + + @Test + fun `billing that never settles fails open`() = runTest { + // Unsettled means "couldn't verify", which must not be turned into "not entitled". + val repo = FakeRepo(pro = false, settled = false) + + repo.isProSettled() shouldBe true + currentTime shouldBe 5_000 + } + + @Test + fun `an initial settled error state fails open`() = runTest { + val repo = FakeRepo(pro = false, settled = true, error = IllegalStateException("billing broke")) + + repo.isProSettled() shouldBe true + } + + @Test + fun `an error published by the refresh fails open`() = runTest { + val repo = FakeRepo(pro = false, settled = false) + repo.onRefresh = { settle(pro = false, error = IllegalStateException("billing broke")) } + + repo.isProSettled() shouldBe true + } + + @Test + fun `a pro state published by a hanging refresh still resolves true`() = runTest { + // The refresh publishes the purchase to the pipeline but its round-trip never returns, so + // the isPro wait (which only starts after refresh()) never runs: the post-window read is + // the only place that can see the pro state — it must honor it, not deny off isSettled. + val repo = FakeRepo(pro = false, settled = true) + repo.onRefresh = { + settle(pro = true) + awaitCancellation() + } + + repo.isProSettled() shouldBe true + currentTime shouldBe 5_000 + } + + @Test + fun `a hanging refresh fails open within the supplied budget`() = runTest { + // The timeout covers refresh + wait: the old shape started the wait only AFTER an unbounded + // refresh, so a hanging Play call could park a task-submit gate far past the window. + val repo = FakeRepo(pro = false, settled = false) + repo.onRefresh = { awaitCancellation() } + + repo.isProSettled(timeout = 2.seconds) shouldBe true + currentTime shouldBe 2_000 + } + + @Test + fun `cancellation during the reconciliation propagates instead of failing open`() = runTest { + val repo = FakeRepo(pro = false, settled = false) + repo.onRefresh = { awaitCancellation() } + + val gate = async { repo.isProSettled() } + runCurrent() + gate.cancel() + + shouldThrow { gate.await() } + } + + @Test + fun `isProSettled errors fail open`() = runTest { + val repo = object : UpgradeRepo { + override val storeSite: String = "" + override val upgradeSite: String = "" + override val betaSite: String = "" + override val upgradeInfo: Flow get() = throw IllegalStateException("billing exploded") + override suspend fun refresh() = Unit + } + + repo.isProSettled() shouldBe true + } + + // endregion + + @Test + fun `pro user resolves true without waiting`() = runTest { + val repo = FakeRepo(pro = true, settled = false) + repo.isProForUi() shouldBe true + currentTime shouldBe 0 + } + + @Test + fun `settled non-pro resolves false without waiting`() = runTest { + // The whole point over isProSettled: a free user's tap must route to the upgrade screen + // immediately, not after a timeout spent waiting for a Pro state that never comes. + val repo = FakeRepo(pro = false, settled = true) + repo.isProForUi() shouldBe false + currentTime shouldBe 0 + } + + @Test + fun `unsettled billing waits and honors the late pro result`() = runTest { + // GPlay cold start: upgradeInfo reports non-Pro until the first billing result. A paying + // user must not be bounced to the upgrade screen by that race. + val repo = FakeRepo(pro = false, settled = false) + + launch { + advanceTimeBy(500) + repo.settle(pro = true) + } + + repo.isProForUi() shouldBe true + } + + @Test + fun `unsettled billing waits and honors the late non-pro result`() = runTest { + val repo = FakeRepo(pro = false, settled = false) + + launch { + advanceTimeBy(500) + repo.settle(pro = false) + } + + repo.isProForUi() shouldBe false + } + + @Test + fun `the settle decision reads ownership off the settling Info itself`() = runTest { + // The old two-step (wait on isSettled, re-read upgradeInfo) could pair the settle signal + // with a stale replay. Now the Info that satisfies the settled-wait IS the decision. + val repo = FakeRepo(pro = false, settled = false) + + launch { + advanceTimeBy(500) + repo.infoFlow.value = FakeInfo(isPro = true, isSettled = true) + } + + repo.isProForUi() shouldBe true + } + + @Test + fun `billing that never settles falls back to non-pro after the timeout`() = runTest { + val repo = FakeRepo(pro = false, settled = false) + repo.isProForUi() shouldBe false + currentTime shouldBe 3_000 + } + + @Test + fun `cancellation during the settle wait propagates instead of failing open`() = runTest { + // A destroyed caller (ViewModel gone mid-wait) must not continue down the Pro path. + val repo = FakeRepo(pro = false, settled = false) + + val gate = async { repo.isProForUi() } + runCurrent() + gate.cancel() + + shouldThrow { gate.await() } + } + + @Test + fun `errors fail open`() = runTest { + val repo = object : UpgradeRepo { + override val storeSite: String = "" + override val upgradeSite: String = "" + override val betaSite: String = "" + override val upgradeInfo: Flow get() = throw IllegalStateException("billing exploded") + override suspend fun refresh() = Unit + } + repo.isProForUi() shouldBe true + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index 11a85fd0..78c4edc1 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -94,6 +94,10 @@ class DeviceSettingsViewModelTest : BaseTest() { devicesFlow = MutableStateFlow(emptyList()) upgradeInfoFlow = MutableStateFlow(mockk(relaxed = true).also { every { it.isPro } returns false + // Hot flow + settled + no error: isProForUi resolves immediately. A finite flowOf or an + // unsettled Info would send every gate through the fail-open timeout path instead. + every { it.isSettled } returns true + every { it.error } returns null }) val syntheticDevice = mockk(relaxed = true).also { diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index e85bf8c1..b57a0cc5 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -82,7 +82,10 @@ class OverviewViewModelTest : BaseTest() { isBluetoothEnabledFlow = MutableStateFlow(true) profilesFlow = MutableStateFlow(emptyList()) hadLegacyReactionDataFlow = MutableStateFlow(false) - upgradeInfoFlow = MutableStateFlow(mockk(relaxed = true)) + upgradeInfoFlow = MutableStateFlow(mockk(relaxed = true).also { + every { it.isSettled } returns true + every { it.error } returns null + }) effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC) fakeReactionsHintDismissed = FakeDataStoreValue(false) fakeHideUnmatchedDevices = FakeDataStoreValue(false) @@ -244,6 +247,8 @@ class OverviewViewModelTest : BaseTest() { fun `free user with no profiled devices - visible empty, hidden 0`() { val upgradeInfo = mockk { every { isPro } returns false + every { isSettled } returns true + every { error } returns null every { type } returns UpgradeRepo.Type.GPLAY } val state = OverviewViewModel.State( @@ -265,6 +270,8 @@ class OverviewViewModelTest : BaseTest() { fun `free user with 1 profiled device - visible 1, hidden 0`() { val upgradeInfo = mockk { every { isPro } returns false + every { isSettled } returns true + every { error } returns null every { type } returns UpgradeRepo.Type.GPLAY } val profiled = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null) @@ -287,6 +294,8 @@ class OverviewViewModelTest : BaseTest() { fun `free user with 3 profiled devices - visible 1, hidden 2`() { val upgradeInfo = mockk { every { isPro } returns false + every { isSettled } returns true + every { error } returns null every { type } returns UpgradeRepo.Type.GPLAY } val device1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null) @@ -311,6 +320,8 @@ class OverviewViewModelTest : BaseTest() { fun `pro user with multiple profiled devices - all visible, hidden 0`() { val upgradeInfo = mockk { every { isPro } returns true + every { isSettled } returns true + every { error } returns null every { type } returns UpgradeRepo.Type.GPLAY } val device1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null) @@ -331,10 +342,66 @@ class OverviewViewModelTest : BaseTest() { state.hiddenProfiledDeviceCount shouldBe 0 } + @Test + fun `unsettled cold start does not truncate a paying users device list`() { + // GPlay seed before the first billing result: non-Pro AND unsettled. Truncating here + // would make a paying user's devices vanish and reappear on every launch. + val upgradeInfo = mockk { + every { isPro } returns false + every { isSettled } returns false + every { error } returns null + every { type } returns UpgradeRepo.Type.GPLAY + } + val device1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null) + val device2 = PodDevice(profileId = "id-2", ble = mockk(relaxed = true), aap = null) + val device3 = PodDevice(profileId = "id-3", ble = mockk(relaxed = true), aap = null) + val state = OverviewViewModel.State( + now = java.time.Instant.now(), + permissions = emptySet(), + devices = listOf(device1, device2, device3), + isDebug = false, + isBluetoothEnabled = true, + profiles = emptyList(), + upgradeInfo = upgradeInfo, + showUnmatchedDevices = false, + ) + + state.visibleProfiledDevices shouldBe listOf(device1, device2, device3) + state.hiddenProfiledDeviceCount shouldBe 0 + } + + @Test + fun `a settled error state does not truncate the device list`() { + // Billing settled into an error: ownership is unknown, so fail open like the gates do. + val upgradeInfo = mockk { + every { isPro } returns false + every { isSettled } returns true + every { error } returns IllegalStateException("billing broke") + every { type } returns UpgradeRepo.Type.GPLAY + } + val device1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null) + val device2 = PodDevice(profileId = "id-2", ble = mockk(relaxed = true), aap = null) + val state = OverviewViewModel.State( + now = java.time.Instant.now(), + permissions = emptySet(), + devices = listOf(device1, device2), + isDebug = false, + isBluetoothEnabled = true, + profiles = emptyList(), + upgradeInfo = upgradeInfo, + showUnmatchedDevices = false, + ) + + state.visibleProfiledDevices shouldBe listOf(device1, device2) + state.hiddenProfiledDeviceCount shouldBe 0 + } + @Test fun `unmatched devices unchanged regardless of pro status`() { val upgradeInfo = mockk { every { isPro } returns false + every { isSettled } returns true + every { error } returns null every { type } returns UpgradeRepo.Type.GPLAY } val profiled1 = PodDevice(profileId = "id-1", ble = mockk(relaxed = true), aap = null) diff --git a/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt index 5ec3ea33..e8942a53 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/presscontrols/PressControlsViewModelTest.kt @@ -70,6 +70,10 @@ class PressControlsViewModelTest : BaseTest() { aapManager = mockk(relaxed = true) upgradeInfoFlow = MutableStateFlow(mockk(relaxed = true).also { every { it.isPro } returns false + // Hot flow + settled + no error: isProForUi resolves immediately. A finite flowOf or an + // unsettled Info would send every gate through the fail-open timeout path instead. + every { it.isSettled } returns true + every { it.error } returns null }) upgradeRepo = mockk { every { upgradeInfo } returns upgradeInfoFlow diff --git a/app/src/test/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModelTest.kt new file mode 100644 index 00000000..fb5f2c72 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModelTest.kt @@ -0,0 +1,203 @@ +package eu.darken.capod.main.ui.settings.general + +import eu.darken.capod.common.navigation.Nav +import eu.darken.capod.common.navigation.NavEvent +import eu.darken.capod.common.theming.ThemeMode +import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.main.core.GeneralSettings +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import testhelpers.BaseTest +import testhelpers.coroutine.TestDispatcherProvider +import testhelpers.datastore.FakeDataStoreValue +import testhelpers.livedata.InstantExecutorExtension +import eu.darken.capod.common.theming.ThemeColor +import eu.darken.capod.common.theming.ThemeStyle + +@ExtendWith(InstantExecutorExtension::class) +class GeneralSettingsViewModelTest : BaseTest() { + + private val testDispatcher = UnconfinedTestDispatcher() + private var vm: GeneralSettingsViewModel? = null + + private lateinit var generalSettings: GeneralSettings + private lateinit var upgradeRepo: UpgradeRepo + private lateinit var upgradeInfoFlow: MutableStateFlow + + private lateinit var themeMode: FakeDataStoreValue + private lateinit var themeStyle: FakeDataStoreValue + private lateinit var themeColor: FakeDataStoreValue + + /** + * Hot flow, never a finite `flowOf`: `isProForUi` waits for a settled emission, and a finished + * flow would push every gate onto its fail-open timeout path instead of the real decision. + */ + private fun info(isPro: Boolean, isSettled: Boolean = true, error: Throwable? = null) = + mockk(relaxed = true).also { + every { it.isPro } returns isPro + every { it.isSettled } returns isSettled + every { it.error } returns error + every { it.type } returns UpgradeRepo.Type.GPLAY + } + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + themeMode = FakeDataStoreValue(ThemeMode.SYSTEM) + themeStyle = FakeDataStoreValue(ThemeStyle.DEFAULT) + themeColor = FakeDataStoreValue(ThemeColor.BLUE) + + generalSettings = mockk().also { + every { it.useExtraMonitorNotification } returns FakeDataStoreValue(false).mock + every { it.keepConnectedNotificationAfterDisconnect } returns FakeDataStoreValue(false).mock + every { it.isOffloadedFilteringDisabled } returns FakeDataStoreValue(false).mock + every { it.isOffloadedBatchingDisabled } returns FakeDataStoreValue(false).mock + every { it.useIndirectScanResultCallback } returns FakeDataStoreValue(false).mock + every { it.hideUnmatchedDevices } returns FakeDataStoreValue(false).mock + every { it.themeMode } returns themeMode.mock + every { it.themeStyle } returns themeStyle.mock + every { it.themeColor } returns themeColor.mock + } + + upgradeInfoFlow = MutableStateFlow(info(isPro = false)) + upgradeRepo = mockk().also { + every { it.upgradeInfo } returns upgradeInfoFlow + } + } + + @AfterEach + fun teardown() { + vm?.vmScope?.cancel() + vm = null + Dispatchers.resetMain() + } + + private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) { + try { + testBody() + } finally { + vm?.vmScope?.cancel() + vm = null + } + } + + private fun createViewModel() = GeneralSettingsViewModel( + dispatcherProvider = TestDispatcherProvider(testDispatcher), + generalSettings = generalSettings, + upgradeRepo = upgradeRepo, + ).also { vm = it } + + // --- Presentation --- + + @Test + fun `a settled free user sees the hard-locked upgrade presentation`() = runVmTest { + upgradeInfoFlow.value = info(isPro = false, isSettled = true) + + createViewModel().state.first().isUpgradeLocked shouldBe true + } + + @Test + fun `the unsettled cold start is not presented as hard-locked`() = runVmTest { + // GPlay seed before the first billing result reports non-Pro even for paying users — + // rendering the upgrade branch here would route them to upgrade without a setter ever + // running (the screen never reaches the isProForUi gate). + upgradeInfoFlow.value = info(isPro = false, isSettled = false) + + createViewModel().state.first().isUpgradeLocked shouldBe false + } + + @Test + fun `a settled error state is not presented as hard-locked`() = runVmTest { + upgradeInfoFlow.value = info(isPro = false, isSettled = true, error = IllegalStateException("nope")) + + createViewModel().state.first().isUpgradeLocked shouldBe false + } + + @Test + fun `a pro user is never hard-locked`() = runVmTest { + upgradeInfoFlow.value = info(isPro = true, isSettled = true) + + createViewModel().state.first().isUpgradeLocked shouldBe false + } + + // --- Setter gates --- + + @Test + fun `a pro user can change the theme mode`() = runVmTest { + upgradeInfoFlow.value = info(isPro = true) + val vm = createViewModel() + vm.state.first() + + vm.setThemeMode(ThemeMode.DARK) + + themeMode.value shouldBe ThemeMode.DARK + } + + @Test + fun `a settled free user is routed to upgrade instead of changing the theme mode`() = runVmTest { + upgradeInfoFlow.value = info(isPro = false) + val vm = createViewModel() + vm.state.first() + + val navEvent = async { vm.navEvents.first() } + vm.setThemeMode(ThemeMode.DARK) + + themeMode.value shouldBe ThemeMode.SYSTEM + navEvent.await().shouldBeInstanceOf().destination shouldBe Nav.Main.Upgrade() + } + + @Test + fun `a paying user tapping during the unsettled cold start still gets the write`() = runVmTest { + // isProForUi waits for the first settled Info instead of denying off the seed. + upgradeInfoFlow.value = info(isPro = false, isSettled = false) + val vm = createViewModel() + vm.state.first() + + vm.setThemeStyle(ThemeStyle.MATERIAL_YOU) + themeStyle.value shouldBe ThemeStyle.DEFAULT + + upgradeInfoFlow.value = info(isPro = true, isSettled = true) + + themeStyle.value shouldBe ThemeStyle.MATERIAL_YOU + } + + @Test + fun `a settled free user is routed to upgrade instead of changing the theme color`() = runVmTest { + upgradeInfoFlow.value = info(isPro = false) + val vm = createViewModel() + vm.state.first() + + val navEvent = async { vm.navEvents.first() } + vm.setThemeColor(ThemeColor.AMBER) + + themeColor.value shouldBe ThemeColor.BLUE + navEvent.await().shouldBeInstanceOf().destination shouldBe Nav.Main.Upgrade() + } + + @Test + fun `non-pro settings are writable without an entitlement`() = runVmTest { + upgradeInfoFlow.value = info(isPro = false) + val vm = createViewModel() + + vm.setHideUnmatchedDevices(true) + + vm.state.first().hideUnmatchedDevices shouldBe true + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateStoreTest.kt b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateStoreTest.kt index c93c7f79..35416b95 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateStoreTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateStoreTest.kt @@ -192,6 +192,7 @@ class AncTileStateStoreTest : BaseTest() { private fun upgradeInfo(isPro: Boolean) = object : UpgradeRepo.Info { override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS override val isPro: Boolean = isPro + override val isSettled: Boolean = true override val upgradedAt: Instant? = null override val error: Throwable? = null } diff --git a/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModelTest.kt new file mode 100644 index 00000000..a6053328 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModelTest.kt @@ -0,0 +1,168 @@ +package eu.darken.capod.main.ui.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.os.Bundle +import androidx.lifecycle.SavedStateHandle +import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.profiles.core.AppleDeviceProfile +import eu.darken.capod.profiles.core.DeviceProfile +import eu.darken.capod.profiles.core.DeviceProfilesRepo +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.coroutine.TestDispatcherProvider + +class WidgetConfigurationViewModelTest : BaseTest() { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testWidgetId = 42 + private val testProfileId = "AA:BB:CC:DD:EE:FF" + private var vm: WidgetConfigurationViewModel? = null + + private lateinit var context: Context + private lateinit var appWidgetManager: AppWidgetManager + private lateinit var widgetSettings: WidgetSettings + private lateinit var profilesRepo: DeviceProfilesRepo + private lateinit var profilesFlow: MutableStateFlow> + private lateinit var upgradeRepo: UpgradeRepo + private lateinit var upgradeInfoFlow: MutableStateFlow + + /** + * Hot flow, never a finite `flowOf`: `isProForUi` waits for a settled emission, and a finished + * flow would push the confirm gate onto its fail-open timeout path instead of the real decision. + */ + private fun info(isPro: Boolean, isSettled: Boolean = true, error: Throwable? = null) = + mockk(relaxed = true).also { + every { it.isPro } returns isPro + every { it.isSettled } returns isSettled + every { it.error } returns error + every { it.type } returns UpgradeRepo.Type.GPLAY + } + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + context = mockk(relaxed = true) + appWidgetManager = mockk().also { + // Null info -> not an ANC widget, so no ComponentName is constructed. + every { it.getAppWidgetInfo(any()) } returns null + every { it.getAppWidgetOptions(any()) } returns mockk(relaxed = true) + } + mockkStatic(AppWidgetManager::class) + every { AppWidgetManager.getInstance(any()) } returns appWidgetManager + + widgetSettings = mockk(relaxed = true).also { + every { it.getWidgetConfig(any()) } returns WidgetConfig(profileId = testProfileId) + } + + profilesFlow = MutableStateFlow( + listOf(AppleDeviceProfile(id = testProfileId, label = "Test", address = testProfileId)) + ) + profilesRepo = mockk().also { + every { it.profiles } returns profilesFlow + } + + upgradeInfoFlow = MutableStateFlow(info(isPro = false)) + upgradeRepo = mockk().also { + every { it.upgradeInfo } returns upgradeInfoFlow + } + } + + @AfterEach + fun teardown() { + vm?.vmScope?.cancel() + vm = null + unmockkStatic(AppWidgetManager::class) + Dispatchers.resetMain() + } + + private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) { + try { + testBody() + } finally { + vm?.vmScope?.cancel() + vm = null + } + } + + private fun createViewModel(widgetId: Int = testWidgetId) = WidgetConfigurationViewModel( + savedStateHandle = SavedStateHandle(mapOf(AppWidgetManager.EXTRA_APPWIDGET_ID to widgetId)), + dispatcherProvider = TestDispatcherProvider(testDispatcher), + deviceProfilesRepo = profilesRepo, + widgetSettings = widgetSettings, + upgradeRepo = upgradeRepo, + context = context, + ).also { vm = it } + + @Test + fun `a settled pro user confirms`() = runVmTest { + upgradeInfoFlow.value = info(isPro = true) + + createViewModel().decideConfirm() shouldBe WidgetConfigurationViewModel.ConfirmOutcome.Confirmed + } + + @Test + fun `a settled free user needs an upgrade`() = runVmTest { + upgradeInfoFlow.value = info(isPro = false) + + createViewModel().decideConfirm() shouldBe WidgetConfigurationViewModel.ConfirmOutcome.UpgradeRequired + } + + @Test + fun `a paying user tapping during the unsettled cold start still confirms`() = runVmTest { + // Without the suspending gate the seed would look free and send an owner shopping again. + upgradeInfoFlow.value = info(isPro = false, isSettled = false) + val vm = createViewModel() + + val outcome = async { vm.decideConfirm() } + upgradeInfoFlow.value = info(isPro = true, isSettled = true) + + outcome.await() shouldBe WidgetConfigurationViewModel.ConfirmOutcome.Confirmed + } + + @Test + fun `billing that never settles falls back to the current state`() = runVmTest { + upgradeInfoFlow.value = info(isPro = false, isSettled = false) + val vm = createViewModel() + + val outcome = async { vm.decideConfirm() } + advanceUntilIdle() + + outcome.await() shouldBe WidgetConfigurationViewModel.ConfirmOutcome.UpgradeRequired + } + + @Test + fun `an invalid widget id is never confirmable`() = runVmTest { + upgradeInfoFlow.value = info(isPro = true) + + val vm = createViewModel(widgetId = AppWidgetManager.INVALID_APPWIDGET_ID) + + vm.decideConfirm() shouldBe WidgetConfigurationViewModel.ConfirmOutcome.Invalid + } + + @Test + fun `a selection without a matching profile is never confirmable`() = runVmTest { + upgradeInfoFlow.value = info(isPro = true) + profilesFlow.value = emptyList() + + createViewModel().decideConfirm() shouldBe WidgetConfigurationViewModel.ConfirmOutcome.Invalid + } +} 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 e5602410..a4c565d7 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 @@ -5,6 +5,7 @@ import com.android.billingclient.api.Purchase import eu.darken.capod.common.WebpageTool import eu.darken.capod.common.navigation.Nav import eu.darken.capod.common.navigation.NavEvent +import eu.darken.capod.common.upgrade.UpgradeRepo 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 @@ -54,14 +55,18 @@ class UpgradeViewModelTest : BaseTest() { return UpgradeRepoGplay.Info( billingData = BillingData(purchases.toList()), upgrades = purchased, + isSettled = true, ) } private fun mockRepo(): UpgradeRepoGplay = mockk(relaxed = true).apply { - every { upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(billingData = null)) + // Settledness rides the Info now: a hot flow whose emission is already settled, so the + // purchase actions aren't gated behind the bounded settle fallback. + every { upgradeInfo } returns MutableStateFlow( + UpgradeRepoGplay.Info(billingData = null, isSettled = true) + ) every { wasEverPro } returns MutableStateFlow(false) every { proUnconfirmedSince } returns MutableStateFlow(0L) - every { isSettled } returns MutableStateFlow(true) // A relaxed mock returns a Flow that never emits — the effectiveRestore combine would // starve and the state flow would never leave Loading. every { autoRestoreBusy } returns MutableStateFlow(false) @@ -130,7 +135,7 @@ class UpgradeViewModelTest : BaseTest() { val repo = mockRepo() coEvery { repo.restorePurchaseNow() } coAnswers { delay(UpgradeViewModel.RESTORE_TIMEOUT_MS * 2) - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) } val vm = createVm(repo) @@ -185,7 +190,7 @@ class UpgradeViewModelTest : BaseTest() { val repo = mockRepo() coEvery { repo.restorePurchaseNow() } coAnswers { delay(5_000) - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) } val vm = createVm(repo) @@ -347,7 +352,7 @@ class UpgradeViewModelTest : BaseTest() { val repo = mockRepo() coEvery { repo.restorePurchaseNow() } coAnswers { delay(5_000) - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) } val vm = createVm(repo) @@ -398,7 +403,7 @@ class UpgradeViewModelTest : BaseTest() { 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) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) val vm = createVm(repo, manage = null) @@ -413,7 +418,7 @@ class UpgradeViewModelTest : BaseTest() { fun `the manage route never auto-closes`() = runTest2 { val repo = mockRepo() every { repo.upgradeInfo } returns MutableStateFlow( - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) val vm = createVm(repo, manage = true) @@ -430,7 +435,7 @@ class UpgradeViewModelTest : BaseTest() { fun `no auto-close before the route is bound`() = runTest2 { val repo = mockRepo() every { repo.upgradeInfo } returns MutableStateFlow( - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) val vm = createVm(repo, manage = null) @@ -480,7 +485,7 @@ class UpgradeViewModelTest : BaseTest() { 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) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(1).toMillis()) val vm = createVm(repo) @@ -495,7 +500,7 @@ class UpgradeViewModelTest : BaseTest() { fun `grace escalates to diagnostics after the threshold`() = runTest2 { val repo = mockRepo() every { repo.upgradeInfo } returns MutableStateFlow( - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(25).toMillis()) val vm = createVm(repo) @@ -510,7 +515,7 @@ class UpgradeViewModelTest : BaseTest() { 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) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) // 2 minutes before the diagnostics threshold. every { repo.proUnconfirmedSince } returns MutableStateFlow( @@ -567,7 +572,7 @@ class UpgradeViewModelTest : BaseTest() { val repo = mockRepo() every { repo.wasEverPro } returns MutableStateFlow(true) every { repo.upgradeInfo } returns MutableStateFlow( - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) val vm = createVm(repo) @@ -579,8 +584,10 @@ class UpgradeViewModelTest : BaseTest() { @Test fun `purchase buttons stay disabled until billing has settled`() = runTest2 { val repo = mockRepo() - val settledFlow = MutableStateFlow(false) - every { repo.isSettled } returns settledFlow + val infoFlow = MutableStateFlow( + UpgradeRepoGplay.Info(billingData = null, isSettled = false) + ) + every { repo.upgradeInfo } returns infoFlow val vm = createVm(repo) val states = mutableListOf() @@ -592,7 +599,7 @@ class UpgradeViewModelTest : BaseTest() { (states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe false (states.last() as UpgradeUiState.Loaded).subscriptionEnabled shouldBe false - settledFlow.value = true + infoFlow.value = UpgradeRepoGplay.Info(billingData = null, isSettled = true) testScheduler.runCurrent() (states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe true @@ -710,7 +717,7 @@ class UpgradeViewModelTest : BaseTest() { // Retry must be disabled then so repeated taps can't thrash the query flow. val repo = mockRepo() every { repo.upgradeInfo } returns MutableStateFlow( - UpgradeRepoGplay.Info(gracePeriod = true, billingData = null) + UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true) ) every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(25).toMillis()) coEvery { repo.querySkus(any()) } coAnswers { awaitCancellation() }