mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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,
|
||||
|
||||
+19
-9
@@ -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
|
||||
}
|
||||
}
|
||||
+4
@@ -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
|
||||
|
||||
+203
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user