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")
}
}