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.
This commit is contained in:
darken
2026-07-29 14:05:26 +02:00
committed by Matthias Urhahn
parent fb48ff9d43
commit 8c1b57a47c
22 changed files with 1047 additions and 75 deletions
@@ -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<UpgradeRepo.Info> = 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")
}
}
@@ -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<Boolean> = billingDataRepo.freshBillingData
.map { true }
.onStart { emit(false) }
.distinctUntilChanged()
override val upgradeInfo: Flow<UpgradeRepo.Info> = combine(
billingDataRepo.billingData
.map<BillingData, BillingData?> { 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<Boolean> = billingDataRepo.freshBillingData
.map { true }
.onStart { emit(false) }
.distinctUntilChanged()
// Strict SUBS-only ownership check for the switch-to-IAP gate. Errors propagate — a
// subscriber whose renewal state can't be verified must not be allowed to double-buy.
suspend fun queryCurrentSubscriptions(): Collection<Purchase> = billingDataRepo.querySubscriptions()
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<PurchasedSku> = 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")
}
}
@@ -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<Boolean> = merge(
upgradeRepo.isSettled.filter { it },
upgradeRepo.upgradeInfo.map { it.isSettled }.filter { it },
flow {
delay(SETTLE_FALLBACK_MS)
emit(true)
@@ -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(
@@ -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<Info>
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
}
}
}
@@ -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
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
}
@@ -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
}
@@ -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<PodDevice>
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<PodDevice> get() = devices.filter { it.profileId == null }
@@ -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
}
@@ -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()
@@ -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,
@@ -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<Any>(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())
@@ -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() },
@@ -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" }
@@ -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<UpgradeRepo.Info>(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<UpgradeRepo.Info> = 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<CancellationException> { 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<UpgradeRepo.Info> 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<CancellationException> { 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<UpgradeRepo.Info> get() = throw IllegalStateException("billing exploded")
override suspend fun refresh() = Unit
}
repo.isProForUi() shouldBe true
}
}
@@ -94,6 +94,10 @@ class DeviceSettingsViewModelTest : BaseTest() {
devicesFlow = MutableStateFlow(emptyList())
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(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<PodDevice>(relaxed = true).also {
@@ -82,7 +82,10 @@ class OverviewViewModelTest : BaseTest() {
isBluetoothEnabledFlow = MutableStateFlow(true)
profilesFlow = MutableStateFlow(emptyList())
hadLegacyReactionDataFlow = MutableStateFlow(false)
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true))
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(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<UpgradeRepo.Info> {
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<UpgradeRepo.Info> {
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<UpgradeRepo.Info> {
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<UpgradeRepo.Info> {
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<UpgradeRepo.Info> {
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<UpgradeRepo.Info> {
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<UpgradeRepo.Info> {
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)
@@ -70,6 +70,10 @@ class PressControlsViewModelTest : BaseTest() {
aapManager = mockk(relaxed = true)
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(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
@@ -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<UpgradeRepo.Info>
private lateinit var themeMode: FakeDataStoreValue<ThemeMode>
private lateinit var themeStyle: FakeDataStoreValue<ThemeStyle>
private lateinit var themeColor: FakeDataStoreValue<ThemeColor>
/**
* 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<UpgradeRepo.Info>(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<GeneralSettings>().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<UpgradeRepo>().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<NavEvent.GoTo>().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<NavEvent.GoTo>().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
}
}
@@ -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
}
@@ -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<List<DeviceProfile>>
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
/**
* 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<UpgradeRepo.Info>(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<AppWidgetManager>().also {
// Null info -> not an ANC widget, so no ComponentName is constructed.
every { it.getAppWidgetInfo(any()) } returns null
every { it.getAppWidgetOptions(any()) } returns mockk<Bundle>(relaxed = true)
}
mockkStatic(AppWidgetManager::class)
every { AppWidgetManager.getInstance(any()) } returns appWidgetManager
widgetSettings = mockk<WidgetSettings>(relaxed = true).also {
every { it.getWidgetConfig(any()) } returns WidgetConfig(profileId = testProfileId)
}
profilesFlow = MutableStateFlow(
listOf(AppleDeviceProfile(id = testProfileId, label = "Test", address = testProfileId))
)
profilesRepo = mockk<DeviceProfilesRepo>().also {
every { it.profiles } returns profilesFlow
}
upgradeInfoFlow = MutableStateFlow(info(isPro = false))
upgradeRepo = mockk<UpgradeRepo>().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
}
}
@@ -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<UpgradeRepoGplay>(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<UpgradeRepo.Info>(
UpgradeRepoGplay.Info(billingData = null, isSettled = false)
)
every { repo.upgradeInfo } returns infoFlow
val vm = createVm(repo)
val states = mutableListOf<UpgradeUiState>()
@@ -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() }