refactor(upgrade): Converge GPlay billing on the canonical stack

Replaces capod's older billing core, upgrade UI and their tests with the
canonical sdmaid-se stack at the pinned revision.

Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on
billing 8.3 with the centralized connect loop, merging purchases-listener
overlay and the canonical ack pipeline; the dying ack collector, the
ackedTokens gate and the in-billing foreground loop are gone. Full canonical
exception set (internal/network/offer-unavailable added), OurSku with capod's
product ids, BillingCache with snapshot()/episode-guarded stampLastProState.

FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API
surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter
records must keep decoding.

Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by
RecorderModule next to CurriculumVitae's Pro history as two independent,
isolated header reads.

UI: canonical upgrade screens for both flavors under common/upgrade/ui with
capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod
previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a
per-resume, unthrottled MainActivity call.

Strings reuse capod's existing translated ids wherever equivalent; only
referenced-but-missing ones are authored.

mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value
while recording, which the ported restore tests need.
This commit is contained in:
darken
2026-07-29 14:05:26 +02:00
committed by Matthias Urhahn
parent 0192ae7081
commit 3651bb3d55
90 changed files with 10067 additions and 6952 deletions
@@ -4,7 +4,8 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
import eu.darken.capod.common.upgrade.core.UpgradeDiagnosticsFoss
import eu.darken.capod.common.upgrade.core.UpgradeRepoFoss
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@@ -12,6 +13,10 @@ import javax.inject.Singleton
abstract class UpgradeModule {
@Binds
@Singleton
abstract fun control(foss: UpgradeControlFoss): UpgradeRepo
abstract fun control(foss: UpgradeRepoFoss): UpgradeRepo
@Binds
@Singleton
abstract fun diagnostics(foss: UpgradeDiagnosticsFoss): UpgradeDiagnostics
}
@@ -1,66 +0,0 @@
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
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
import eu.darken.capod.common.datastore.valueBlocking
@Singleton
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()
} else {
Info(
isPro = true,
upgradedAt = data.upgradedAt,
upgradeReason = data.reason
)
}
}
fun upgrade(reason: FossUpgrade.Reason) {
fossCache.upgrade.valueBlocking = FossUpgrade(
upgradedAt = Instant.now(),
reason = reason
)
}
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,
val upgradeReason: FossUpgrade.Reason? = null,
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
}
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")
}
}
@@ -0,0 +1,15 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
import javax.inject.Inject
import javax.inject.Singleton
/**
* FOSS has no store entitlement to reconcile: the upgrade state is a local sponsor record, already
* covered by the existing header fields. Nothing to add.
*/
@Singleton
class UpgradeDiagnosticsFoss @Inject constructor() : UpgradeDiagnostics {
override suspend fun debugInfo(): String? = null
}
@@ -0,0 +1,94 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import java.time.Instant
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UpgradeRepoFoss @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val fossCache: FossCache,
private val webpageTool: WebpageTool,
) : UpgradeRepo {
override val storeSite: String = STORE_SITE
override val upgradeSite: String = UPGRADE_SITE
override val betaSite: String = BETA_SITE
private val refreshTrigger = MutableStateFlow(UUID.randomUUID())
override val upgradeInfo: Flow<UpgradeRepo.Info> = combine(
fossCache.upgrade.flow,
refreshTrigger
) { data, _ ->
if (data == null) {
Info()
} else {
Info(
isPro = true,
upgradedAt = data.upgradedAt,
upgradeReason = data.reason,
)
}
}
.setupCommonEventHandlers(TAG) { "upgradeInfo" }
.shareIn(appScope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
fun openGithubSponsorsPage() = appScope.launch {
log(TAG) { "openGithubSponsorsPage()" }
webpageTool.open(upgradeSite)
}
// Writes capod's RETAINED persistence schema: existing supporter records are serialized with
// `reason` (foss.upgrade.reason.*). Adopting canonical's `upgradeType` schema would decode
// every stored record as null and strip those supporters' entitlement.
internal suspend fun persistUpgrade() {
log(TAG) { "persistUpgrade()" }
fossCache.upgrade.value(
FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.DONATED,
)
)
}
override suspend fun refresh() {
log(TAG) { "refresh()" }
refreshTrigger.value = UUID.randomUUID()
}
data class Info(
override val isPro: Boolean = false,
override val upgradedAt: Instant? = null,
val upgradeReason: FossUpgrade.Reason? = null,
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
}
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", "Repo")
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.upgrade.ui
package eu.darken.capod.common.upgrade.ui
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
@@ -13,7 +13,7 @@ import javax.inject.Inject
class UpgradeNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.Upgrade> { key -> UpgradeScreenHost(manage = key.manage) }
entry<Nav.Main.Upgrade> { key -> UpgradeScreenHost(route = key) }
}
@Module
@@ -0,0 +1,299 @@
package eu.darken.capod.common.upgrade.ui
import android.widget.Toast
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome
import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Info
import androidx.compose.material.icons.twotone.Verified
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.navigation.Nav
import androidx.compose.ui.unit.dp
// Which presentation the FOSS upgrade screen shows: the classic support pitch, or one of the
// status views behind the settings "upgrade status" entry.
internal enum class FossUpgradeView {
PITCH,
STATUS_FREE,
STATUS_UPGRADED,
}
@Composable
fun UpgradeScreenHost(
route: Nav.Main.Upgrade = Nav.Main.Upgrade(),
vm: UpgradeViewModel = hiltViewModel(),
) {
LaunchedEffect(route) { vm.bindRoute(route) }
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val sponsorReturnTracker = remember { SponsorReturnTracker() }
LaunchedEffect(Unit) {
vm.snackbarEvents.collect { stringRes ->
snackbarHostState.showSnackbar(context.getString(stringRes))
}
}
LaunchedEffect(Unit) {
vm.toastEvents.collect { stringRes ->
Toast.makeText(context, context.getString(stringRes), Toast.LENGTH_LONG).show()
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
sponsorReturnTracker.onStop()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (sponsorReturnTracker.consumeResumeReturn()) {
vm.checkSponsorReturn()
}
}
val view by vm.state.collectAsStateWithLifecycle()
UpgradeScreen(
// Until the route binding lands (one frame): the default route keeps rendering the pitch
// exactly as before, only the manage route waits for the status decision.
view = view ?: FossUpgradeView.PITCH.takeIf { !route.manage },
snackbarHostState = snackbarHostState,
onGithubSponsors = vm::goGithubSponsors,
onShowUpgradeOptions = vm::onShowUpgradeOptions,
onNavigateUp = vm::navUp,
)
}
@Composable
internal fun UpgradeScreen(
view: FossUpgradeView? = FossUpgradeView.PITCH,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onGithubSponsors: () -> Unit = {},
onShowUpgradeOptions: () -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
UpgradeScreenScaffold(
// Status views describe the existing install, not a support ask — they get the composed
// flavor title, with the postfix highlighted for supporters like the dashboard does it.
title = if (view == FossUpgradeView.PITCH) {
AnnotatedString(stringResource(R.string.settings_upgrade_status_label))
} else {
upgradeScreenTitle(upgraded = view == FossUpgradeView.STATUS_UPGRADED)
},
onNavigateUp = onNavigateUp,
snackbarHostState = snackbarHostState,
) { paddingValues ->
when (view) {
null -> Unit // Route not bound yet (single frame); content lands with the next state.
FossUpgradeView.PITCH -> UpgradePitchContent(
paddingValues = paddingValues,
onGithubSponsors = onGithubSponsors,
)
FossUpgradeView.STATUS_FREE -> UpgradeStatusFreeContent(
paddingValues = paddingValues,
onShowUpgradeOptions = onShowUpgradeOptions,
)
FossUpgradeView.STATUS_UPGRADED -> UpgradeStatusUpgradedContent(
paddingValues = paddingValues,
onGithubSponsors = onGithubSponsors,
)
}
}
}
@Composable
private fun UpgradePitchContent(
paddingValues: PaddingValues,
onGithubSponsors: () -> Unit,
) {
UpgradeScreenContent(
paddingValues = paddingValues,
) {
UpgradeHeader(
mascotSize = 104.dp,
)
UpgradePreambleCard(
text = stringResource(R.string.upgrade_foss_preamble),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_why_title),
icon = Icons.TwoTone.AutoAwesome,
) {
UpgradeFeatureList(text = upgradeBenefitsText())
}
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_how_title),
icon = Icons.TwoTone.Favorite,
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_how_body))
}
UpgradeActionCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
),
) {
Button(
onClick = onGithubSponsors,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.FOSS_SPONSOR),
) {
Text(stringResource(R.string.upgrade_foss_sponsor_action))
}
UpgradeHintText(text = stringResource(R.string.upgrade_foss_sponsor_subtitle))
}
}
}
@Composable
private fun UpgradeStatusFreeContent(
paddingValues: PaddingValues,
onShowUpgradeOptions: () -> Unit,
) {
UpgradeScreenContent(
paddingValues = paddingValues,
) {
UpgradeHeader(
mascotSize = 104.dp,
)
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_status_free_title),
icon = Icons.TwoTone.Info,
modifier = Modifier.testTag(UpgradeScreenTags.FOSS_STATUS_FREE),
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_status_free_body))
Button(
onClick = onShowUpgradeOptions,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS),
) {
Text(stringResource(R.string.upgrade_screen_status_free_action))
}
}
}
}
@Composable
private fun UpgradeStatusUpgradedContent(
paddingValues: PaddingValues,
onGithubSponsors: () -> Unit,
) {
UpgradeScreenContent(
paddingValues = paddingValues,
) {
UpgradeHeader(
mascotSize = 104.dp,
)
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_status_upgraded_title),
icon = Icons.TwoTone.Verified,
modifier = Modifier.testTag(UpgradeScreenTags.FOSS_STATUS_UPGRADED),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(
text = stringResource(R.string.upgrade_foss_supporter_thanks),
style = MaterialTheme.typography.bodyMedium,
)
}
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_recurring_title),
icon = Icons.TwoTone.Favorite,
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_recurring_body))
OutlinedButton(
onClick = onGithubSponsors,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.FOSS_DONATE),
) {
Text(stringResource(R.string.upgrade_foss_sponsor_again_action))
}
}
}
}
internal class SponsorReturnTracker {
private var wentToBackground = false
fun onStop() {
wentToBackground = true
}
fun consumeResumeReturn(): Boolean {
return if (wentToBackground) {
wentToBackground = false
true
} else {
false
}
}
}
@Preview2
@Composable
private fun UpgradeScreenPreview() {
PreviewWrapper {
UpgradeScreen()
}
}
@Preview2
@Composable
private fun UpgradeScreenStatusFreePreview() {
PreviewWrapper {
UpgradeScreen(view = FossUpgradeView.STATUS_FREE)
}
}
@Preview2
@Composable
private fun UpgradeScreenStatusUpgradedPreview() {
PreviewWrapper {
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED)
}
}
@@ -0,0 +1,127 @@
package eu.darken.capod.common.upgrade.ui
import android.os.SystemClock
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
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.core.UpgradeRepoFoss
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
private val handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepoFoss,
) : ViewModel4(dispatcherProvider = dispatcherProvider) {
// Route is bound from the Host via bindRoute(); SavedStateHandle.toRoute<>() crashes under Nav3.
private val routeFlow = MutableStateFlow<Nav.Main.Upgrade?>(null)
fun bindRoute(route: Nav.Main.Upgrade) {
if (routeFlow.value != null) return
routeFlow.value = route
}
val snackbarEvents = SingleEventFlow<Int>()
val toastEvents = SingleEventFlow<Int>()
// Which presentation the screen shows. The manage route (settings "upgrade status" entry)
// gets a status view first; the pitch only appears once a free user asks for the upgrade
// options. Upgrading wins over that choice — completing the sponsor flow from the pitch must
// land on the upgraded status, not back on the ask. null until the route is bound.
internal val state: StateFlow<FossUpgradeView?> = combine(
routeFlow,
upgradeRepo.upgradeInfo,
handle.getStateFlow(KEY_SHOW_UPGRADE_OPTIONS, false),
) { route, info, showOptions ->
when {
route == null -> null
route.manage && info.isPro -> FossUpgradeView.STATUS_UPGRADED
route.manage && !showOptions -> FossUpgradeView.STATUS_FREE
else -> FossUpgradeView.PITCH
}
}.safeStateIn(
initialValue = null,
onError = { FossUpgradeView.PITCH },
)
init {
routeFlow
.filterNotNull()
.take(1)
.onEach { route ->
// The manage route is the settings "upgrade status" entry — upgraded users must
// not be bounced out. Forced routes keep their existing don't-auto-close semantics.
if (!route.forced && !route.manage) {
upgradeRepo.upgradeInfo
.filter { it.isPro }
.take(1)
.onEach { navUp() }
.launchInViewModel()
}
}
.launchInViewModel()
upgradeRepo.upgradeInfo
.filter { !it.isPro && it.error != null }
.onEach { current ->
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
errorEvents.tryEmit(current.error!!)
}
.launchInViewModel()
}
fun onShowUpgradeOptions() {
log(TAG) { "onShowUpgradeOptions()" }
// Handle-backed: surviving process recreation keeps the user on the pitch they asked for.
handle[KEY_SHOW_UPGRADE_OPTIONS] = true
}
fun goGithubSponsors() {
log(TAG) { "goGithubSponsors()" }
handle[KEY_SPONSOR_PRESSED_AT] = SystemClock.elapsedRealtime()
upgradeRepo.openGithubSponsorsPage()
}
fun checkSponsorReturn() = launch {
val pressedAt = handle.remove<Long>(KEY_SPONSOR_PRESSED_AT) ?: return@launch
val elapsed = SystemClock.elapsedRealtime() - pressedAt
log(TAG) { "checkSponsorReturn(): elapsed=${elapsed}ms" }
if (elapsed < SPONSOR_DELAY_MS) {
// The nudge belongs to the unlock heuristic. An already upgraded user (recurring
// donation button) has nothing to unlock — peeking at the page needs no feedback.
if (upgradeRepo.upgradeInfo.first().isPro) {
log(TAG) { "checkSponsorReturn(): Too quick, but already upgraded, staying quiet" }
} else {
log(TAG) { "checkSponsorReturn(): Too quick, showing snackbar" }
snackbarEvents.tryEmit(R.string.upgrade_foss_sponsor_returned_early)
}
} else {
log(TAG) { "checkSponsorReturn(): Delay passed, persisting upgrade" }
upgradeRepo.persistUpgrade()
toastEvents.tryEmit(R.string.upgrade_foss_supporter_thanks)
}
}
companion object {
private const val KEY_SPONSOR_PRESSED_AT = "sponsor_pressed_at"
private const val KEY_SHOW_UPGRADE_OPTIONS = "show_upgrade_options"
private const val SPONSOR_DELAY_MS = 5_000L
private val TAG = logTag("Upgrade", "ViewModel")
}
}
@@ -1,445 +0,0 @@
package eu.darken.capod.upgrade.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun UpgradeScreenHost(
manage: Boolean = false,
vm: UpgradeViewModel = hiltViewModel(),
) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val snackbarHostState = remember { SnackbarHostState() }
val returnedEarlyMessage = stringResource(R.string.upgrade_foss_sponsor_returned_early)
val state by vm.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
vm.onResume()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(Unit) {
vm.sponsorEvents.collect { event ->
when (event) {
UpgradeViewModel.SponsorEvent.ReturnedTooEarly -> {
snackbarHostState.showSnackbar(returnedEarlyMessage)
}
}
}
}
val current = state
when {
// DataStore hasn't answered yet — render nothing rather than flashing the sales route
// (with its armable unlock heuristic) at an existing supporter.
current == null -> Unit
manage && current.isPro -> {
// Existing supporter checking their status: no sales pitch, and the sponsor link here
// must not re-run the unlock heuristic (which would rewrite the supporter-since date).
SupporterStatusScreen(
upgradedAt = current.upgradedAt,
onNavigateUp = { vm.navUp() },
onSponsorPage = { vm.openSponsorPage() },
)
}
else -> UpgradeScreen(
snackbarHostState = snackbarHostState,
onNavigateUp = { vm.navUp() },
onSponsor = { vm.sponsor() },
)
}
}
@Composable
fun SupporterStatusScreen(
upgradedAt: java.time.Instant?,
onNavigateUp: () -> Unit,
onSponsorPage: () -> Unit,
) {
Scaffold(
containerColor = MaterialTheme.colorScheme.surface,
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(paddingValues)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
Box(contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(120.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
) {}
Image(
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = Modifier.size(80.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = buildAnnotatedString {
append("CAPod ")
withStyle(SpanStyle(color = colorResource(R.color.brand_secondary), fontWeight = FontWeight.Bold)) {
append("FOSS")
}
},
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(24.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier
.fillMaxWidth()
.testTag("upgrade.foss.supporterCard"),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Favorite,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = upgradedAt
?.let { instant ->
stringResource(
R.string.upgrade_foss_supporter_since,
remember(instant) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
.format(instant)
}
)
}
?: stringResource(R.string.upgrade_foss_supporter_thanks),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.upgrade_foss_supporter_thanks),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onSponsorPage,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(
imageVector = Icons.TwoTone.Favorite,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.upgrade_foss_sponsor_again_action),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
.padding(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
}
}
}
private data class Benefit(val icon: ImageVector, val textRes: Int)
@Composable
fun UpgradeScreen(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onNavigateUp: () -> Unit,
onSponsor: () -> Unit,
) {
val benefits = listOf(
Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay),
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
)
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface,
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(paddingValues)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
Box(contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(120.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
) {}
Image(
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = Modifier.size(80.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = buildAnnotatedString {
append("CAPod ")
withStyle(SpanStyle(color = colorResource(R.color.brand_secondary), fontWeight = FontWeight.Bold)) {
append("FOSS")
}
},
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(24.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.upgrade_foss_preamble),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
benefits.forEach { benefit ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = benefit.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(benefit.textRes),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
Text(
text = stringResource(R.string.upgrade_benefit_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSponsor,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(
imageVector = Icons.TwoTone.Favorite,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.upgrade_foss_sponsor_action),
style = MaterialTheme.typography.titleMedium,
)
}
Text(
text = stringResource(R.string.upgrade_foss_sponsor_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(24.dp))
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
.padding(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
}
}
}
@Preview2
@Composable
private fun UpgradeScreenPreview() = PreviewWrapper {
UpgradeScreen(
onNavigateUp = {},
onSponsor = {},
)
}
@Preview2
@Composable
private fun SupporterStatusScreenPreview() = PreviewWrapper {
SupporterStatusScreen(
upgradedAt = java.time.Instant.parse("2025-11-02T12:00:00Z"),
onNavigateUp = {},
onSponsorPage = {},
)
}
@@ -1,70 +0,0 @@
package eu.darken.capod.upgrade.ui
import android.os.SystemClock
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.FossUpgrade
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val upgradeControlFoss: UpgradeControlFoss,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
sealed interface SponsorEvent {
data object ReturnedTooEarly : SponsorEvent
}
val sponsorEvents = SingleEventFlow<SponsorEvent>()
data class State(
val isPro: Boolean = false,
val upgradedAt: Instant? = null,
)
// Null until DataStore answered: a defaulted isPro=false would flash the sales route (and its
// armable unlock heuristic) at an existing supporter opening their status.
val state: StateFlow<State?> = upgradeControlFoss.upgradeInfo
.map { info -> State(isPro = info.isPro, upgradedAt = info.upgradedAt) }
.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), null)
fun sponsor() {
savedStateHandle[KEY_SPONSOR_OPENED_AT] = SystemClock.elapsedRealtime()
webpageTool.open("https://github.com/sponsors/d4rken")
}
// Plain sponsor link for existing supporters: must NOT arm the unlock heuristic — re-running
// it would rewrite the "supporter since" date and navigate away from the status view.
fun openSponsorPage() {
webpageTool.open("https://github.com/sponsors/d4rken")
}
fun onResume() {
val openedAt = savedStateHandle.get<Long>(KEY_SPONSOR_OPENED_AT) ?: return
savedStateHandle.remove<Long>(KEY_SPONSOR_OPENED_AT)
if (SystemClock.elapsedRealtime() - openedAt >= 5_000L) {
upgradeControlFoss.upgrade(FossUpgrade.Reason.DONATED)
navUp()
} else {
sponsorEvents.tryEmit(SponsorEvent.ReturnedTooEarly)
}
}
companion object {
private const val KEY_SPONSOR_OPENED_AT = "sponsor_opened_at"
}
}
+9
View File
@@ -13,4 +13,13 @@
<string name="upgrade_foss_sponsor_again_action">Open sponsor page</string>
<string name="settings_upgrade_status_label">Sponsor CAPod</string>
<string name="settings_upgrade_status_description">Your supporter status.</string>
<string name="upgrade_screen_why_title">Upgrade benefits</string>
<string name="upgrade_screen_how_title">How to help</string>
<string name="upgrade_screen_how_body">Become a patron and sponsor development! Tap the button below to activate all extra features and open my GitHub Sponsors profile.</string>
<string name="upgrade_screen_status_free_title">Free version</string>
<string name="upgrade_screen_status_free_body">You are using the free version of CAPod. Extra features can be unlocked by supporting development.</string>
<string name="upgrade_screen_status_free_action">See upgrade options</string>
<string name="upgrade_screen_status_upgraded_title">Upgrade active</string>
<string name="upgrade_screen_recurring_title">Keep it going</string>
<string name="upgrade_screen_recurring_body">CAPod keeps evolving through updates and fixes. If you\'d like to sustain that, consider a recurring donation via GitHub Sponsors.</string>
</resources>