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 -3
View File
@@ -32,9 +32,10 @@ Extend `testhelpers.BaseTest`, or the applicable specialized base that already e
`BaseTest` installs a `JUnitLogger` and calls `unmockkAll()` in `@AfterAll`. Skipping it can leave
global mockk and logging state behind for later test classes.
The only exceptions are the two Robolectric-backed Compose UI tests
(`UpgradeScreenFossComposeTest`, `UpgradeScreenComposeTest`), which use JUnit 4 `@RunWith`/`@Rule`
via `junit-vintage-engine`. Don't copy that pattern for a plain unit test.
The only exceptions are the Robolectric-backed tests (Compose UI via
`testhelpers.compose.BaseComposeRobolectricTest`, and the few DataStore-backed ones such as
`CurriculumVitaeProHistoryTest`), which use JUnit 4 `@RunWith`/`@Rule` via `junit-vintage-engine`.
Don't copy that pattern for a plain unit test.
## Source sets and Gradle tasks
+2 -2
View File
@@ -195,8 +195,8 @@ dependencies {
addTesting()
"gplayImplementation"("com.android.billingclient:billing:8.0.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:8.0.0")
"gplayImplementation"("com.android.billingclient:billing:8.3.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:8.3.0")
// Robolectric-backed Compose UI tests (run as regular unit tests via the vintage engine).
testImplementation(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
@@ -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>
@@ -4,6 +4,7 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.upgrade.core.UpgradeDiagnosticsGplay
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import javax.inject.Singleton
@@ -14,4 +15,8 @@ abstract class UpgradeModule {
@Singleton
abstract fun control(gplay: UpgradeRepoGplay): UpgradeRepo
@Binds
@Singleton
abstract fun diagnostics(gplay: UpgradeDiagnosticsGplay): UpgradeDiagnostics
}
@@ -9,64 +9,85 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.datastore.basicReader
import eu.darken.capod.common.datastore.basicWriter
import eu.darken.capod.common.datastore.createValue
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
private val Context.gplayDataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings_gplay",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_gplay")) }
)
@Singleton
class BillingCache internal constructor(
private val dataStore: DataStore<Preferences>,
class BillingCache @Inject constructor(
@ApplicationContext private val context: Context,
) {
@Inject constructor(@ApplicationContext context: Context) : this(context.gplayDataStore)
// Retained legacy migration: installs that predate the DataStore move still carry their upgrade
// state in the "settings_gplay" SharedPreferences file.
private val Context.dataStore by preferencesDataStore(
name = "settings_gplay",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_gplay")) },
)
val lastProStateAt = dataStore.createValue(KEY_LAST_PRO_AT.name, 0L)
private val dataStore: DataStore<Preferences>
get() = context.dataStore
// SKU id of the last confirmed Pro purchase — determines which grace window applies.
// Empty for legacy installs that were Pro before this field existed.
val lastProStateSku = dataStore.createValue(KEY_LAST_PRO_SKU.name, "")
// Raw keys shared between the DataStoreValues and stampLastProState's transaction — one
// source of truth for key name and encoding.
private val lastProStateAtKey = longPreferencesKey("gplay.cache.lastProAt")
private val lastProStateSkuKey = stringPreferencesKey("gplay.cache.lastProSku")
private val proUnconfirmedSinceKey = longPreferencesKey("gplay.cache.proUnconfirmedAt")
// Start of the current "fresh data can't confirm Pro" episode, 0 = no open episode. Drives
// the two-stage grace UI (calm confirmation phase first, diagnostics once the episode ages).
val proUnconfirmedAt = dataStore.createValue(KEY_PRO_UNCONFIRMED_AT.name, 0L)
val lastProStateAt = dataStore.createValue(
key = lastProStateAtKey,
reader = basicReader(0L),
writer = basicWriter(),
)
val lastProStateSku = dataStore.createValue(
key = lastProStateSkuKey,
reader = basicReader(""),
writer = basicWriter(),
)
// One transaction: a confirmed Pro purchase stamps the anchor (SKU only when the caller wants
// to move it) and atomically closes any unconfirmed episode. Observers and crash recovery
// must never see the anchor updated but the episode still open, or vice versa.
suspend fun stampLastProState(skuId: String?, at: Long) {
// Start of the current "fresh data can't confirm Pro" episode (0 = none/confirmed). Drives the
// delayed grace hint on the upgrade screen; stamped only from fresh billing reconciliations —
// see UpgradeRepoGplay.recordProUnconfirmed().
val proUnconfirmedSince = dataStore.createValue(
key = proUnconfirmedSinceKey,
reader = basicReader(0L),
writer = basicWriter(),
)
// Point-in-time view of all three values. Reading them via three separate .value() calls can
// straddle a concurrent stampLastProState() and observe a combination that never existed --
// that write is transactional precisely because the values are only meaningful together.
data class Snapshot(
val lastProStateAt: Long,
val lastProStateSku: String,
val proUnconfirmedSince: Long,
)
suspend fun snapshot(): Snapshot {
val prefs = dataStore.data.first()
return Snapshot(
lastProStateAt = prefs[lastProStateAtKey] ?: 0L,
lastProStateSku = prefs[lastProStateSkuKey] ?: "",
proUnconfirmedSince = prefs[proUnconfirmedSinceKey] ?: 0L,
)
}
// One transaction for all three values: the timestamp gates the grace period, the SKU modifies
// its window length, and a confirmation closes the unconfirmed episode — none of it may be
// observable half-updated. `at` is the confirmation's OCCURRENCE time (commit time of the Play
// round-trip). The episode is closed only if it began at or before `at`: a failure that occurred
// AFTER this confirmation (e.g. a connection drop right after this success, delivered to the
// entitlement layer out of order) opened a still-valid episode that this older confirmation must
// not erase.
suspend fun stampLastProState(skuId: String, at: Long) {
dataStore.edit { prefs ->
skuId?.let { prefs[KEY_LAST_PRO_SKU] = it }
prefs[KEY_LAST_PRO_AT] = at
prefs[KEY_PRO_UNCONFIRMED_AT] = 0L
prefs[lastProStateSkuKey] = skuId
prefs[lastProStateAtKey] = at
val episodeStart = prefs[proUnconfirmedSinceKey] ?: 0L
if (episodeStart in 1..at) prefs[proUnconfirmedSinceKey] = 0L
}
}
// Starts the unconfirmed episode clock. Set-if-unset: follow-up failures must not push the
// diagnostics threshold out. An episode only exists relative to a previous confirmation, and
// a stored stamp from before that confirmation or from the future is corrupt state that gets
// repaired instead of trusted.
suspend fun recordProUnconfirmed(at: Long) {
dataStore.edit { prefs ->
val lastProAt = prefs[KEY_LAST_PRO_AT] ?: 0L
// Also rejects failures arriving moments after a confirmation: a confirmation and a
// conflicting empty snapshot within the same minute is emission reordering around a
// racing purchase event, not a real unconfirmed state.
if (lastProAt <= 0L || at - lastProAt < MIN_CONFIRMATION_AGE_MS) return@edit
val current = prefs[KEY_PRO_UNCONFIRMED_AT] ?: 0L
val corrupt = current != 0L && (current <= lastProAt || current > at)
if (current == 0L || corrupt) prefs[KEY_PRO_UNCONFIRMED_AT] = at
}
}
companion object {
private val KEY_LAST_PRO_AT = longPreferencesKey("gplay.cache.lastProAt")
private val KEY_LAST_PRO_SKU = stringPreferencesKey("gplay.cache.lastProSku")
private val KEY_PRO_UNCONFIRMED_AT = longPreferencesKey("gplay.cache.proUnconfirmedAt")
internal const val MIN_CONFIRMATION_AGE_MS = 60_000L
}
}
@@ -1,34 +0,0 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.upgrade.core.data.Sku
interface CapodSku {
interface Iap : CapodSku {
object PRO_UPGRADE : Sku.Iap, Iap {
override val id = "${BuildConfigWrap.APPLICATION_ID}.iap.upgrade.pro"
}
}
interface Sub : CapodSku {
object PRO_UPGRADE : Sku.Subscription, Sub {
override val id = "upgrade.pro"
override val offers = setOf(BASE_OFFER, TRIAL_OFFER)
object BASE_OFFER : Sku.Subscription.Offer {
override val basePlanId = "upgrade-pro-baseplan"
override val offerId: String? = null
}
object TRIAL_OFFER : Sku.Subscription.Offer {
override val basePlanId = "upgrade-pro-baseplan"
override val offerId = "upgrade-pro-baseplan-trial"
}
}
}
companion object {
val PRO_SKUS: Set<Sku> = setOf(Sub.PRO_UPGRADE, Iap.PRO_UPGRADE)
}
}
@@ -0,0 +1,35 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.upgrade.core.billing.Sku
@Suppress("ClassName")
interface OurSku {
interface Iap : OurSku {
object PRO_UPGRADE : Sku.Iap, Iap {
override val id: String = "eu.darken.capod.iap.upgrade.pro"
}
}
interface Sub : Sku.Subscription {
object PRO_UPGRADE : Sku.Subscription, Sub {
override val id: String = "upgrade.pro"
override val offers: Collection<Sku.Subscription.Offer> = setOf(
BASE_OFFER, TRIAL_OFFER
)
object BASE_OFFER : Sku.Subscription.Offer {
override val basePlanId: String = "upgrade-pro-baseplan"
override val offerId: String? = null
}
object TRIAL_OFFER : Sku.Subscription.Offer {
override val basePlanId: String = "upgrade-pro-baseplan"
override val offerId: String = "upgrade-pro-baseplan-trial"
}
}
}
companion object {
val PRO_SKUS = setOf(Sub.PRO_UPGRADE, Iap.PRO_UPGRADE)
}
}
@@ -0,0 +1,31 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reports the local billing cache into the debug log header.
*
* `lastProStateAt > 0` is the "this install once confirmed a real Pro purchase" bit. It predates
* the CurriculumVitae pro-state counters by years and lives in a DataStore that was never migrated
* or renamed, so it survives update chains that the newer counters can't speak to. Without it in
* the header, a purchase complaint can't be told apart from a never-bought install.
*
* Depends on [BillingCache] alone -- see [UpgradeDiagnostics] for why this must not pull in
* UpgradeRepoGplay.
*/
@Singleton
class UpgradeDiagnosticsGplay @Inject constructor(
private val billingCache: BillingCache,
) : UpgradeDiagnostics {
override suspend fun debugInfo(): String {
val snapshot = billingCache.snapshot()
val lastProAt = snapshot.lastProStateAt.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) } ?: "never"
val lastProSku = snapshot.lastProStateSku.takeIf { it.isNotEmpty() } ?: "unknown/legacy"
val unconfirmedSince = snapshot.proUnconfirmedSince.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) } ?: "none"
return "BillingCache(lastProStateAt=$lastProAt, lastProStateSku=$lastProSku, proUnconfirmedSince=$unconfirmedSince)"
}
}
@@ -1,10 +1,11 @@
package eu.darken.capod.common.upgrade.core
import android.app.Activity
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -13,74 +14,96 @@ 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 eu.darken.capod.common.upgrade.core.client.ItemAlreadyOwnedBillingException
import eu.darken.capod.common.upgrade.core.data.BillingData
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo
import eu.darken.capod.common.upgrade.core.data.FreshBillingData
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
import eu.darken.capod.common.upgrade.core.data.Sku
import eu.darken.capod.common.upgrade.core.data.SkuDetails
import eu.darken.capod.common.upgrade.core.billing.BillingData
import eu.darken.capod.common.upgrade.core.billing.BillingManager
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.billing.ItemAlreadyOwnedBillingException
import eu.darken.capod.common.upgrade.core.billing.PurchasedSku
import eu.darken.capod.common.upgrade.core.billing.Sku
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
import eu.darken.capod.common.upgrade.core.billing.UserCanceledBillingException
import eu.darken.capod.common.upgrade.core.billing.client.redacted
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.runningReduce
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import eu.darken.capod.main.core.CurriculumVitae
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
import eu.darken.capod.common.datastore.value
@Singleton
class UpgradeRepoGplay @Inject constructor(
@AppScope private val scope: CoroutineScope,
private val billingDataRepo: BillingDataRepo,
private val billingManager: BillingManager,
private val billingCache: BillingCache,
private val timeSource: TimeSource,
private val curriculumVitae: CurriculumVitae,
) : 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.
// Coalescing single-flight for the invisible already-owned recoveries: overlapping triggers
// (async Play event racing a buy tap's launch result) join the SAME restore instead of
// stacking concurrent Play queries. Busy state is exact because at most one job runs.
private val autoRestoreLock = Mutex()
private var autoRestoreJob: Deferred<Info?>? = null
private val autoRestoreState = MutableStateFlow(false)
// The already-owned auto-restores run invisibly on AppScope; expose their busy state so the
// UI can pause entitlement actions instead of racing them with a manual restore or a buy.
val autoRestoreBusy: Flow<Boolean> = autoRestoreState
// Process-wide single-flight for Play launches: the launch runs on AppScope and outlives the
// ViewModel that started it, so a VM-level guard alone lets a rotation (or a second screen)
// start a competing purchase flow. Holds the SKU being launched, null while idle.
private val launchBusySku = MutableStateFlow<Sku?>(null)
// Which purchase launch (if any) is currently in flight, so the UI can present the busy state
// even for a launch a previous ViewModel instance started.
val purchaseLaunchSku: StateFlow<Sku?> = launchBusySku
// Test seam: the launch body runs on AppScope (a real dispatcher), so a virtual-time test
// cannot advance the production bound. Same pattern as UpgradeViewModel's `clock`.
internal var launchTimeoutMs: Long = LAUNCH_TIMEOUT_MS
// Serializes the pro-state recorders: the fresh-data collector and the failure paths in
// refresh()/restorePurchaseNow() can run concurrently, and a stale unconfirmed-stamp read must
// not undo a newer confirmation's episode clear. Declared before the init block — its
// collector can run during construction.
private val proStateLock = Mutex()
// True while the invisible already-owned recovery (the async ITEM_ALREADY_OWNED collector below)
// is restoring. The ViewModel gates buy actions on it so a buy tap can't race the silent restore
// and buy the OTHER product on top of what the user already owns (a different-SKU double charge —
// the ITEM_ALREADY_OWNED reconciliation only covers the same SKU).
private val autoRestoreBusyState = MutableStateFlow(false)
val autoRestoreBusy: StateFlow<Boolean> = autoRestoreBusyState.asStateFlow()
init {
// Fresh-provenance grace stamping: freshBillingData carries every successful query result
// and push payload as an event — unlike the equality-deduped billingData state, an
// unchanged steady-owner query still stamps, and stale listener data can't sneak in.
// The reactive upgradeInfo mapping deliberately writes nothing anymore.
billingDataRepo.freshBillingData
// Grace bookkeeping is driven by *fresh* Play data only: freshBillingData emissions each
// represent an actual Play round-trip (per-connection/manual query results, completed
// purchase events) — never the replayed billingData/upgradeInfo flows, whose old data
// must not keep re-stamping the grace window (e.g. after a refund).
billingManager.freshBillingData
.onEach { fresh ->
try {
recordProState(fresh)
trackProState(fresh)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -91,399 +114,486 @@ class UpgradeRepoGplay @Inject constructor(
.setupCommonEventHandlers(TAG) { "proStateRecorder" }
.launchIn(scope)
// Failed fresh-data attempts (query errors, timeouts) start the unconfirmed-episode clock.
// Most of these failures are swallowed by their pipelines (logged, retried later), so
// without this collector a sustained Play outage would never age the grace presentation
// from "confirming..." into its diagnostics stage.
billingDataRepo.refreshFailures
.onEach {
try {
proStateLock.withLock {
billingCache.recordProUnconfirmed(timeSource.currentTimeMillis())
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to record unconfirmed state: ${e.asLog()}" }
}
}
.setupCommonEventHandlers(TAG) { "unconfirmedRecorder" }
.launchIn(scope)
// Async variant of the launch-result ITEM_ALREADY_OWNED case: Play told us mid-flow that
// the user already owns it. Reconcile silently — Play shows its own UI for purchase-sheet
// failures, so no app-side dialog here.
billingDataRepo.purchaseFailures
.filter { it.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED }
billingManager.purchaseFailures
.filter { it.responseCode == BillingResponseCode.ITEM_ALREADY_OWNED }
.onEach {
log(TAG, INFO) { "Async already-owned event -> restoring purchase" }
autoRestoreBusyState.value = true
try {
withTimeoutOrNull(RESTORE_ON_OWNED_TIMEOUT_MS) { restorePurchaseNow() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Async already-owned restore failed: ${e.asLog()}" }
} finally {
autoRestoreBusyState.value = false
}
autoRestore()
}
.setupCommonEventHandlers(TAG) { "asyncAlreadyOwned" }
.launchIn(scope)
// Connect-loop failures never reach an explicit refresh() caller (the loop retries
// internally and downstream flows just go quiet), so without this a sustained Play outage
// between ON_RESUME refreshes wouldn't advance the grace episode clock. The emitted value is
// the failure's occurrence time: recordProUnconfirmed uses it (not processing-time) so a
// buffered failure that a later success already superseded is dropped rather than reopening a
// closed episode. Set-if-unset and grace-guarded, so repeated outages collapse to a single
// episode start and installs that were never Pro (lastProStateAt == 0) are ignored.
billingManager.connectionFailures
.onEach { failedAt -> recordProUnconfirmed(failedAt) }
.setupCommonEventHandlers(TAG) { "connectionFailureRecorder" }
.launchIn(scope)
}
// Grace window depends on what was last owned: a permanent one-time purchase should almost
// never be dropped on a Play hiccup, so it gets a long window; a subscription legitimately
// lapses, so it keeps the short one (also used for unknown/legacy last SKUs). Suspend + the
// cancellable value() read (not valueBlocking's runBlocking) so a hung DataStore read can be
// cancelled/retried instead of pinning a dispatcher thread.
private suspend fun graceWindowMs(): Long =
if (billingCache.lastProStateSku.value().isIapSku()) GRACE_PERIOD_IAP_MS else GRACE_PERIOD_MS
// Was the last confirmed Pro state within the grace window? Guarded AND bounded: a read failure
// — the same DataStore a caller may have just failed on — is treated as "not recently Pro"
// rather than propagating; a hung read is bounded by a timeout so it can't wedge the sequential
// upgradeInfo mapping and block a later confirmed purchase behind it. Shared by the reactive
// mapping, the reactive retry and the direct restore so their grace decision can't diverge.
private suspend fun isRecentlyPro(): Boolean = try {
val recent = withTimeoutOrNull(GRACE_PROBE_TIMEOUT_MS) {
(timeSource.currentTimeMillis() - billingCache.lastProStateAt.value()) < graceWindowMs()
// Settledness travels WITH the ownership data (Info.isSettled), never on a parallel flow —
// a parallel signal could be observed out of step and pair "settled" with a stale non-Pro
// seed for one emission (the old cosmetic flash at owners).
//
// Per emission: data != null means a COMMITTED Play round-trip happened by construction
// (BillingConnection.purchases only emits after refreshPurchases committed under the reducer
// lock, and the manager only publishes the connection after that refresh succeeded). Note:
// committed, not necessarily complete — combinePurchaseResults tolerates one failed product
// type when the other returned a purchase, so a partial snapshot also settles (same as the
// old signal; grace covers a recently-confirmed Pro whose type failed). A null seed settles
// only via isFailureSettled: Play is unreachable, so seed + grace mapping IS the best
// knowledge. Accepted residual: isFailureSettled is sticky, so after a failure-then-recovery
// a fresh resubscribe can briefly pair the seed with settled=true before the data replay
// lands — identical to the old signal's stickiness, covered by grace + the UI Loading gates;
// the pure-success-path race (settled leading the FIRST reconciliation) is now impossible.
override val upgradeInfo: Flow<Info> = combine(
billingManager.billingData
.map<BillingData, BillingData?> { it }
.onStart { emit(null) },
billingManager.isFailureSettled,
) { data, failureSettled -> data to (data != null || failureSettled) }
.setupCommonEventHandlers(TAG) { "upgradeInfo1" }
.map { (data, settled) -> data.toUpgradeInfo(settled = settled) }
.distinctUntilChanged()
.retryWhen { error, attempt ->
if (error is CancellationException) return@retryWhen false
// Billing connection errors can no longer reach this flow (the connect loop retries
// them internally) — what CAN fail here are the LOCAL DataStore reads in the Pro
// mapping, plausible exactly when storage is full. Keep the flow alive and keep a
// recently-Pro user in their grace window.
log(TAG, WARN) { "upgradeInfo mapping failed (attempt=$attempt): ${error.asLog()}" }
// Fallbacks are settled: a local storage failure is a definitive best-knowledge
// outcome — gates must resolve now, not stall out a 30s+ retry backoff.
val fallback = try {
if ((System.currentTimeMillis() - billingCache.lastProStateAt.value()) < graceWindowMs()) {
Info(gracePeriod = true, billingData = null, isSettled = true)
} else {
Info(billingData = null, error = error, isSettled = true)
}
if (recent == null) log(TAG, WARN) { "Grace probe timed out, treating as not-recently-pro" }
recent ?: false
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Grace probe read failed, treating as not-recently-pro: ${e.asLog()}" }
false
// The grace probe reads the same storage that just failed — a second failure must
// not kill the retry loop that exists for exactly this situation.
Info(billingData = null, error = error, isSettled = true)
}
// 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, 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
// Grace is time-based, but billingData is equality-deduped state kept hot by a
// process-lifetime subscriber — without this deadline tick, a lapsed grace window would keep
// isPro=true until the next distinct billing emission or a process restart.
//
// Storage-failure resilient: the leading onStart emits immediately so a confirmed purchase in
// billingData never waits on the first DataStore read (combine can fire, and toUpgradeInfo()'s
// mapped-first branch surfaces the purchase without touching the cache). The trailing retryWhen
// catches a failure from EITHER the lastProStateAt.flow source OR graceWindowMs() and keeps the
// tick alive (emit + capped backoff), so a broken cache can't starve combine or terminate the
// stream.
private val graceDeadlineTick: Flow<Unit> = billingCache.lastProStateAt.flow
.flatMapLatest { lastProAt ->
flow {
emit(Unit)
if (lastProAt > 0L) {
val remaining = lastProAt + graceWindowMs() - timeSource.currentTimeMillis()
if (remaining > 0) {
delay(remaining)
emit(Unit)
}
}
}
}
.onStart { emit(Unit) }
.retryWhen { error, attempt ->
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "graceDeadlineTick failed (attempt=$attempt): ${error.asLog()}" }
emit(Unit)
emit(fallback)
delay(retryDelayMs(attempt))
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,
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
// graceDeadlineTick keeps itself alive. This only fires on a genuinely unexpected
// upstream error — keep the flow alive (a terminal .catch would complete the shared flow
// and the process-lifetime subscriber would never let it recover) and emit a guarded
// fallback rather than terminating.
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "upgradeInfo mapping failed unexpectedly (attempt=$attempt): ${error.asLog()}" }
emit(graceOrError(error))
delay(retryDelayMs(attempt))
true
// Settledness is monotonic within a subscription span: the retry above resubscribes the
// upstream, whose onStart re-emits the null seed — without this latch, that seed would
// regress an already-settled stream back to unsettled until the billing replay lands.
.runningReduce { acc, next ->
if (acc.isSettled && !next.isSettled) next.copy(isSettled = true) else next
}
.setupCommonEventHandlers(TAG) { "upgradeInfo2" }
.shareIn(scope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
// True once we've ever confirmed a known Pro purchase on this install; drives the proactive
// True once we've ever confirmed a (known) Pro purchase on this install; drives the proactive
// restore banner. Local signal only — a fresh install or switched Google account starts false.
// Fail-soft: this is combined in the ViewModel OUTSIDE the hardened upgradeInfo, so a DataStore
// read failure here would otherwise terminate that combine and strand the screen even when
// upgradeInfo correctly reports Pro. On failure fall back to false and retry.
// Fail-soft, and that matters more here than the value itself: this is combined into the whole
// upgrade-screen state, whose safeStateIn fallback is TERMINAL (it catches, so the retry button
// can't revive the stream). A full-disk DataStore would otherwise take the entire screen down
// until it is reopened, over a decoration. Degrade to "not previously pro" instead — entitlement
// does not depend on this, upgradeInfo reads the cache on its own retrying path.
val wasEverPro: Flow<Boolean> = billingCache.lastProStateAt.flow
.map { it > 0 }
.retryWhen { error, attempt ->
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "wasEverPro read failed (attempt=$attempt): ${error.asLog()}" }
.catch { e ->
if (e is CancellationException) throw e
log(TAG, WARN) { "wasEverPro read failed: ${e.asLog()}" }
emit(false)
delay(retryDelayMs(attempt))
true
}
.distinctUntilChanged()
// Start of the current "fresh data can't confirm Pro" episode (0 = none open). Drives the
// two-stage grace UI: calm confirmation phase first, diagnostics once the episode has aged.
// Fail-soft for the same reason as wasEverPro: fall back to 0 (no open episode) and retry.
val proUnconfirmedSince: Flow<Long> = billingCache.proUnconfirmedAt.flow
.retryWhen { error, attempt ->
if (error is CancellationException) return@retryWhen false
log(TAG, WARN) { "proUnconfirmedSince read failed (attempt=$attempt): ${error.asLog()}" }
// Epoch millis of the first fresh reconciliation that couldn't confirm Pro in the current grace
// episode (0 = none). The upgrade screen delays its grace diagnostics until this has aged, so
// self-healing Play blips never surface it.
val proUnconfirmedSince: Flow<Long> = billingCache.proUnconfirmedSince.flow
// Same fail-soft reasoning as wasEverPro — 0 reads as "no episode", i.e. the quiet grace stage.
.catch { e ->
if (e is CancellationException) throw e
log(TAG, WARN) { "proUnconfirmedSince read failed: ${e.asLog()}" }
emit(0L)
delay(retryDelayMs(attempt))
true
}
.distinctUntilChanged()
// Suspends until the Play launch resolved (sheet up, or failed) — callers holding an
// in-progress guard (e.g. the IAP verification single-flight) stay guarded through the
// launch. Still runs ON AppScope: the purchase flow and the already-owned recovery must
// survive the upgrade screen being closed, so caller cancellation only abandons the await.
suspend fun launchBillingFlowNow(
activity: Activity,
sku: Sku,
offer: Sku.Subscription.Offer?,
onError: (Throwable) -> Unit,
) {
scope.async { launchBillingFlowInternal(activity, sku, offer, onError) }.await()
}
// 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()
private suspend fun launchBillingFlowInternal(
activity: Activity,
sku: Sku,
offer: Sku.Subscription.Offer?,
onError: (Throwable) -> Unit,
) {
log(TAG) { "launchBillingFlow($activity,$sku)" }
// Silent coalesce, no error event: a second tap (or a second ViewModel instance after a
// rotation) must not open a competing Play sheet. Cleared in the finally below, so the next
// deliberate tap works. The already-owned recovery inside only restores, it never re-enters
// this function.
if (!launchBusySku.compareAndSet(null, sku)) {
log(TAG, WARN) { "Billing launch already in flight, ignoring" }
return
}
try {
// Bounded, like every other Play path (refresh, restore, SKU query, ack). useConnection
// waits for a healthy connection indefinitely, so a Play outage between rendering the
// offers and this tap would park the launch forever — with launchBusySku still held,
// which leaves every purchase button busy and never surfaces an error. Generous on
// purpose: a cold Play can take >8s just for the SKU query this launch does first.
// Residual: a timeout landing in the millisecond window between launchBillingFlow's
// binder call and its result shows an error while the sheet opens. The purchase itself
// is unaffected — it arrives via onPurchasesUpdated, independent of this coroutine.
withTimeoutOrNull(launchTimeoutMs) {
billingManager.startIapFlow(activity, sku, offer)
} ?: throw GplayServiceUnavailableException(
RuntimeException("Billing flow launch timed out for ${sku.id}")
)
} catch (e: CancellationException) {
// Not an error: must not reach onError (spurious dialog) — rethrow for structured
// cancellation.
throw e
} catch (e: Exception) {
when {
e is UserCanceledBillingException -> log(TAG) { "User canceled billing flow" }
e is ItemAlreadyOwnedBillingException -> {
// Stale local state: Play says they already own it, so tapping "buy" really
// means "unlock what I own" — restore instead of showing an error.
log(TAG, INFO) { "Launch says already owned -> restoring purchase" }
val restored = autoRestore()
// Reconciled only if the restore actually returned the SKU Play claims is
// owned — a grace-only isPro doesn't count, the entitlement is still missing.
if (restored?.upgrades?.any { it.sku == sku } != true) {
// Couldn't reconcile the entitlement (pending purchase, account mismatch,
// Play quirk) — fall back to the already-owned dialog with restore tips.
onError(e)
}
}
else -> {
log(TAG) { "startIapFlow failed:${e.asLog()}" }
onError(e)
}
}
} finally {
// Released on ANY termination, including the already-owned recovery path and caller
// cancellation: a launch that is over must not block the next one.
launchBusySku.value = null
}
}
// Bounded, silent restore for the already-owned recovery paths. Coalescing: a trigger that
// arrives while one is running awaits the running one. Returns null when the restore failed
// or timed out — never throws (except cancellation of the AWAITING caller; the job itself
// finishes on AppScope either way).
private suspend fun autoRestore(): Info? {
val job = autoRestoreLock.withLock {
autoRestoreJob?.takeIf { it.isActive } ?: scope.async {
autoRestoreState.value = true
try {
// Provenance is irrelevant here: the caller only checks whether the claimed SKU
// came back, and a grace-only result doesn't reconcile it either way.
withTimeoutOrNull(RESTORE_ON_OWNED_TIMEOUT_MS) { restorePurchaseNow().info }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Already-owned restore failed: ${e.asLog()}" }
null
} finally {
autoRestoreState.value = false
}
}.also { autoRestoreJob = it }
}
return job.await()
}
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> = billingManager.querySkus(*skus)
// Strict subscription lookup for the pre-purchase gate: fresh SUBS-only query with explicit
// failure. No grace substitution and no cross-product-type tolerance (unlike refresh() and
// restorePurchaseNow()) — callers must treat any error as "couldn't verify" and fail closed.
suspend fun queryCurrentSubscriptions(): Collection<Purchase> {
log(TAG) { "queryCurrentSubscriptions()" }
return billingManager.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" }
// background callers (MainViewModel, isProSettled gates) suspended indefinitely.
// Grace stamping happens via the freshBillingData collector, not here.
val fresh = withTimeoutOrNull(REFRESH_TIMEOUT_MS) { billingManager.refresh() }
if (fresh == null) {
// A hanging connection is also a fresh attempt that couldn't confirm Pro.
recordProUnconfirmed()
}
} 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()}" }
// Background refresh: keep the old swallow-and-log behaviour so callers like MainViewModel
// aren't affected. The explicit restore path uses restorePurchaseNow(), which surfaces errors.
log(TAG, ERROR) { "Background refresh failed: ${e.asLog()}" }
// A fresh attempt that FAILED also can't confirm Pro — without this, a sustained Play
// outage (queries erroring, never empty-succeeding) would never start the episode clock.
recordProUnconfirmed()
}
}
// 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".
suspend fun restorePurchaseNow(): Info {
log(TAG) { "restorePurchaseNow()" }
// 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".
suspend fun restorePurchaseNow(): RestoreOutcome {
// INFO: pairs with the refreshPurchases() outcome line so a support log shows which Play
// round-trip belongs to an explicit restore tap.
log(TAG, INFO) { "restorePurchaseNow()" }
return try {
val fresh = billingDataRepo.refresh()
// Returned data is fresh by definition — stamp it even if the flows dedupe the
// unchanged result and the init collector never sees a new emission. Best-effort: a
// failed cache write must not turn a successful Play restore into the grace/error path
// (the mapped info below is returned regardless).
try {
recordProState(fresh)
// A restore result IS a real Play round-trip outcome -> settled.
RestoreOutcome.Checked(billingManager.refresh().toUpgradeInfo(settled = true))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "restore: failed to record pro state: ${e.asLog()}" }
}
// A completed Play round-trip is settled knowledge by definition.
fresh.data.toUpgradeInfo(settled = true)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// A transient Play error (or a cache read failure in the mapping) while we were Pro
// recently keeps us Pro via grace; otherwise surface the error so the caller can show the
// proper "Play unavailable" message instead of a generic restore failure. isRecentlyPro
// guards its own probe, so a second failure of the same broken cache resolves to "throw
// 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, isSettled = true)
// Mirror the reactive flow's retryWhen: a transient Play error while we were Pro recently
// keeps us Pro via the grace period; otherwise surface the error so the caller can show
// the proper "Play unavailable" message instead of a generic restore failure.
val lastProStateAt = billingCache.lastProStateAt.value()
if ((System.currentTimeMillis() - lastProStateAt) < graceWindowMs()) {
log(TAG, VERBOSE) { "restore hit a Play error but we were Pro recently -> grace" }
recordProUnconfirmed()
// Grace keeps Pro, but the lookup itself never landed. Reported as Inconclusive so
// the UI can't claim a completed check: an owner in grace is exactly who must not
// be told "we checked Play and found nothing".
RestoreOutcome.Inconclusive(Info(gracePeriod = true, billingData = null, isSettled = true), e)
} else {
throw e
}
}
}
/**
* Provenance of an explicit restore, kept apart from [Info] so entitlement stays untouched.
*
* [Info] alone can't carry this: a grace-substituted `Info(gracePeriod = true, billingData =
* null)` is produced both by a successful empty query (a real answer) and by a swallowed Play
* error (no answer at all). Those need opposite UI treatment.
*/
sealed interface RestoreOutcome {
val info: Info
/** Play answered. [info] reflects a real entitlement lookup. */
data class Checked(override val info: Info) : RestoreOutcome
/** Play couldn't be reached; [info] is grace-substituted and ownership stays unknown. */
data class Inconclusive(override val info: Info, val cause: Throwable) : RestoreOutcome
}
// Reports the Pro-state transition history to CurriculumVitae (grace engaged, Pro lost) —
// lifetime counters that end up in every debug log recording. Fed from FRESH data only:
// upgradeInfo's null-seeded replay would fake a PURCHASED->GRACE->PURCHASED round trip on
// every app launch. A partial snapshot without a known upgrade proves nothing about absence,
// so it records nothing — grace engagements during a TOTAL Play outage are only counted once
// Play answers again (accepted trade-off: no false positives).
private suspend fun trackProState(fresh: BillingManager.FreshData) {
val info = Info(billingData = fresh.data)
val state = when {
info.upgrades.isNotEmpty() -> CurriculumVitae.ProState.PURCHASED
!fresh.isFullSnapshot -> return
(System.currentTimeMillis() - billingCache.lastProStateAt.value()) < graceWindowMs() ->
CurriculumVitae.ProState.GRACE
else -> CurriculumVitae.ProState.FREE
}
curriculumVitae.updateProState(state)
}
// Persists "we saw a known Pro purchase" for the grace machinery, or feeds the unconfirmed-
// episode clock when fresh data can't confirm Pro. Only ever fed by the freshBillingData
// collector — fresh Play round-trips, never replayed flow data, so a refunded purchase can't
// keep re-stamping its grace window.
private suspend fun recordProState(fresh: BillingManager.FreshData) = proStateLock.withLock {
val sku = preferredProSku(Info(billingData = fresh.data).upgrades)
if (sku == null) {
// A full snapshot proves absence; a partial one (purchase event, single-type query)
// only proves presence of what it contains and must not start an unconfirmed episode.
// occurredAt is the snapshot's commit time — when Play confirmed the absence.
if (fresh.isFullSnapshot) recordProUnconfirmedLocked(fresh.occurredAt)
return@withLock
}
val storedSkuId = billingCache.lastProStateSku.value()
val storedType = OurSku.PRO_SKUS.singleOrNull { it.id == storedSkuId }?.type
// A non-full snapshot (purchase event, partial refresh) proves ownership of what it
// contains, but not the ABSENCE of anything else: it must not downgrade the grace class of
// a previously confirmed permanent IAP (30d) to the subscription window (7d). Only a full
// snapshot, where Play confirmed the IAP is really gone, may do that.
val effectiveSkuId = if (
!fresh.isFullSnapshot && storedType == Sku.Type.IAP && sku.type != Sku.Type.IAP
) {
storedSkuId
} else {
sku.id
}
log(TAG, VERBOSE) { "Fresh Pro state confirmed by $sku, stamping $effectiveSkuId" }
// Stamp with the confirmation's OWN commit time, not processing-now: the same value gates
// which unconfirmed episode this closes (BillingCache only clears an episode that began at or
// before it) and lets a later connection failure be correctly ordered against this success.
billingCache.stampLastProState(effectiveSkuId, fresh.occurredAt)
}
private suspend fun recordProUnconfirmed(occurredAt: Long = System.currentTimeMillis()) =
proStateLock.withLock { recordProUnconfirmedLocked(occurredAt) }
// Fresh reconciliation failed to confirm a known Pro purchase (full-snapshot empty result or
// query error). Starts the unconfirmed-episode clock that delays the grace hint on the upgrade
// screen. Set-if-unset so follow-up failures never refresh it; stamps from an earlier episode
// (older than the last confirmation) or from the future (clock changes) are replaced.
// Fail-quiet: purely informational, must never affect entitlement handling.
//
// occurredAt is WHEN the failure happened. Imperative callers (refresh/restore/empty snapshot)
// pass the default (now), which is also their event time. The buffered connectionFailures feed
// passes the failure's own timestamp so that a failure which happened BEFORE the latest
// confirmation — e.g. one enqueued during an outage but consumed only after a later retry
// succeeded and stamped Pro — is rejected by the sinceConfirm <= 0 guard instead of reopening
// an episode Play already closed.
private suspend fun recordProUnconfirmedLocked(occurredAt: Long = System.currentTimeMillis()) {
try {
val lastProStateAt = billingCache.lastProStateAt.value()
val sinceConfirm = occurredAt - lastProStateAt
// sinceConfirm <= 0 rejects failures superseded by a later confirmation AND future
// confirmations (clock moved backwards) — both would otherwise pass the window check and
// (re)stamp the episode.
if (lastProStateAt <= 0L || sinceConfirm <= 0L || sinceConfirm >= graceWindowMs()) return
billingCache.proUnconfirmedSince.update { current ->
val stale = current <= 0L || current < lastProStateAt || current > occurredAt
if (stale) occurredAt else current
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to record unconfirmed pro state: ${e.asLog()}" }
}
}
// Shared Pro/grace mapping used by both the reactive upgradeInfo flow and restorePurchaseNow().
// Only relinquishes Pro if we haven't had it for a while (grace period). READ-ONLY: this also
// runs on replayed shared-flow data, so it must never stamp the grace cache — a refunded
// purchase could otherwise keep re-stamping its own grace window. See recordProState().
//
// Branch on MAPPED upgrades before any cache read: a confirmed known purchase is Pro even when
// 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.
//
// Only relinquishes Pro if we haven't had it for a while (grace period). READ-ONLY: this runs on
// replayed shared-flow data too, so it must never stamp the grace cache — see recordProState().
// 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,
)
// Branch on MAPPED upgrades, not raw purchases: a purchase list containing only products
// this app doesn't know maps to zero upgrades and must fall through to the grace check —
// otherwise a recently-Pro user is denied grace they're entitled to. A known purchase is
// decided before any grace-cache read, so failing local storage can't turn a confirmed
// purchase into an error episode.
val mapped = Info(billingData = this, isSettled = settled)
if (mapped.upgrades.isNotEmpty()) return mapped
// No confirmed purchase (incl. the null pre-data placeholder the combine seeds): fall back to
// the grace window via the guarded+bounded probe. Routing through isRecentlyPro() — instead
// of reading the cache inline — means a failing/hung DataStore can NOT throw out of this
// mapping. If it could, the map's exception would tear down the flow and the retry would
// re-inject the null placeholder, looping forever and never processing a later confirmed
// purchase that arrives behind it in the sequential map.
return if (isRecentlyPro()) {
val now = System.currentTimeMillis()
val lastProStateAt = billingCache.lastProStateAt.value()
log(TAG) { "toUpgradeInfo(): now=$now, lastProStateAt=$lastProStateAt, data=$this" }
return when {
(now - lastProStateAt) < graceWindowMs() -> {
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null, isSettled = settled)
} else {
mapped
}
else -> mapped
}
}
// Persists what fresh data told us about Pro ownership. Callers must only pass FRESH data
// (returned query results, or new emissions seen by the init collector) — never replayed flow
// data. A confirmed purchase stamps the anchor and atomically closes any unconfirmed episode;
// a full snapshot WITHOUT a Pro purchase conclusively failed to confirm and starts the episode
// clock; presence-only data without a purchase proves nothing either way. The permanent IAP
// wins as anchor when both are owned, and an IAP anchor is sticky: purchase data may lack the
// IAP because that query failed or was out of scope (SUBS-only verification), and a
// subscription seen in the meantime must not shrink the 30d window of an owner whose IAP was
// never disproven (trade-off: a refunded IAP keeps the long window — consistent with the
// fail-open cache). Locked: runs concurrently from the init collector, failure events and
// direct restores, and the sticky check-then-write must not race.
private suspend fun recordProState(fresh: FreshBillingData) {
val upgrades = fresh.data.getProSkus()
val preferred = preferredProSku(upgrades)
proStateLock.withLock {
when {
preferred != null -> {
val anchorIsIap = billingCache.lastProStateSku.value().isIapSku()
val anchorSku = preferred
.takeIf { it.type == Sku.Type.IAP || !anchorIsIap }
?.id
billingCache.stampLastProState(anchorSku, timeSource.currentTimeMillis())
}
fresh.isFullSnapshot -> {
billingCache.recordProUnconfirmed(timeSource.currentTimeMillis())
}
}
}
// Grace window depends on what was last owned: a permanent one-time purchase gets a long window,
// a subscription (or an unknown/legacy last SKU) gets the short default.
private suspend fun graceWindowMs(): Long {
val lastSku = billingCache.lastProStateSku.value()
val type = OurSku.PRO_SKUS.singleOrNull { it.id == lastSku }?.type
val window = if (type == Sku.Type.IAP) GRACE_PERIOD_IAP_MS else GRACE_PERIOD_MS
log(TAG) { "graceWindowMs(): lastSku=$lastSku, type=$type -> ${window}ms" }
return window
}
data class Info(
private val gracePeriod: Boolean = false,
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.
// Default false is the fail-safe direction: a forgotten stamp shows up as "never
// settles" (loud), never as a settled pre-reconciliation flash. Mapping-only
// constructions (trackProState, recordProState) never read this.
override val isSettled: Boolean = false,
) : UpgradeRepo.Info {
override val type: UpgradeRepo.Type
get() = UpgradeRepo.Type.GPLAY
override val type: UpgradeRepo.Type = UpgradeRepo.Type.GPLAY
override val isPro: Boolean
get() = billingData?.getProSku() != null || gracePeriod
val hasIap: Boolean
get() = upgrades.any { it.sku is Sku.Iap }
val hasSub: Boolean
get() = upgrades.any { it.sku is Sku.Subscription }
override val upgradedAt: Instant?
get() = billingData
?.getProSku()
?.purchase?.purchaseTime
?.let { Instant.ofEpochMilli(it) }
val upgrades: Collection<PurchasedSku> = billingData?.purchases
?.map { purchase ->
purchase.products.mapNotNull { productId ->
val sku = OurSku.PRO_SKUS.singleOrNull { it.id == productId }
if (sku == null) {
log(TAG, ERROR) { "Unknown product: $productId (${purchase.redacted()})" }
return@mapNotNull null
} else {
log(TAG) { "Mapped $productId to $sku (${purchase.redacted()})" }
}
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> = billingDataRepo.querySkus(*skus)
suspend fun launchBillingFlow(
activity: Activity,
sku: Sku,
offer: Sku.Subscription.Offer? = null,
) {
try {
billingDataRepo.startBillingFlow(activity, sku, offer)
} catch (e: ItemAlreadyOwnedBillingException) {
// Stale local state: Play says they already own it, so tapping "buy" really means
// "unlock what I own" — restore instead of showing an error. Success is silent, the
// reactive upgradeInfo emission closes the upgrade screen.
log(TAG, INFO) { "Launch says already owned -> restoring purchase" }
val restored = try {
withTimeoutOrNull(RESTORE_ON_OWNED_TIMEOUT_MS) { restorePurchaseNow() }
} catch (re: CancellationException) {
throw re
} catch (re: Exception) {
log(TAG, WARN) { "Restore after already-owned failed: ${re.asLog()}" }
null
}
// Only the LAUNCHED entitlement showing up explains the already-owned launch failure —
// a grace-only isPro or a different owned SKU doesn't reconcile it. Fall back to the
// already-owned dialog with restore tips otherwise.
val reconciled = restored?.upgrades?.any { it.sku.id == sku.id } == true
if (!reconciled) throw e
PurchasedSku(sku, purchase)
}
}
?.flatten()
?: emptySet()
override val isPro: Boolean = upgrades.isNotEmpty() || gracePeriod
override val upgradedAt: Instant? = upgrades
.maxByOrNull { it.purchase.purchaseTime }
?.let { Instant.ofEpochMilli(it.purchase.purchaseTime) }
}
companion object {
private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus
.firstOrNull { it.sku in CapodSku.PRO_SKUS }
private fun BillingData.getProSkus(): Collection<PurchasedSku> = purchasedSkus
.filter { it.sku in CapodSku.PRO_SKUS }
// Keep paying users Pro through transient empty/failed Play Billing responses. A permanent
// one-time purchase should almost never be dropped on a hiccup, so it gets a long window;
// a subscription legitimately lapses, so it keeps the short one. GRACE_PERIOD_MS is the
// subscription/default window (also used when the last-owned SKU is unknown/legacy).
val GRACE_PERIOD_MS = Duration.ofDays(7).toMillis()
val GRACE_PERIOD_IAP_MS = Duration.ofDays(30).toMillis()
// The SKU whose grace window applies when several are owned: the permanent one-time
// purchase wins over a subscription (purchases are time-sorted, so a plain first() could
// pick a newer subscription and shrink the window). Null when nothing known is owned.
internal fun preferredProSku(upgrades: Collection<PurchasedSku>): Sku? =
upgrades.firstOrNull { it.sku.type == Sku.Type.IAP }?.sku ?: upgrades.firstOrNull()?.sku
// Backoff for the local-DataStore-failure retries in upgradeInfo and graceDeadlineTick:
// 30s/60s/120s/240s, capped at 5min. Integer math on purpose — a Double-pow formula could
// overflow into a hot loop at extreme attempt counts. Pure and unit-tested.
internal fun retryDelayMs(attempt: Long): Long =
if (attempt >= 4) 300_000L else 30_000L shl attempt.toInt()
// Upper bound on a single grace-cache probe: a hung DataStore read resolves to "not
// recently pro" instead of wedging the sequential upgradeInfo mapping behind it.
private const val GRACE_PROBE_TIMEOUT_MS = 2_000L
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"
// Keep paying users Pro through transient empty/failed Play Billing responses. A permanent
// one-time purchase should almost never be dropped on a hiccup, so it gets a long window; a
// subscription legitimately lapses, so it keeps the short one. GRACE_PERIOD_MS is the
// subscription/default window (also used when the last-owned SKU is unknown/legacy).
val GRACE_PERIOD_MS = Duration.ofDays(7).toMillis()
val GRACE_PERIOD_IAP_MS = Duration.ofDays(30).toMillis()
private const val RESTORE_ON_OWNED_TIMEOUT_MS = 15_000L
private const val REFRESH_TIMEOUT_MS = 30_000L
val TAG: String = logTag("Upgrade", "Gplay", "Control")
// Covers connecting, the SKU query the launch does first, and the sheet launch itself.
// Matches REFRESH_TIMEOUT_MS: both bound "connection wait + Play round-trip".
internal const val LAUNCH_TIMEOUT_MS = 30_000L
val TAG: String = logTag("Upgrade", "Gplay", "Repo")
// The SKU whose grace window applies when several are owned: the permanent one-time purchase
// wins over a subscription (purchases are time-sorted, so firstOrNull alone isn't enough).
// null when no known Pro SKU is owned.
internal fun preferredProSku(upgrades: Collection<PurchasedSku>): Sku? =
upgrades.firstOrNull { it.sku.type == Sku.Type.IAP }?.sku ?: upgrades.firstOrNull()?.sku
// Backoff for the local-failure retry in upgradeInfo: 30s/60s/120s/240s, capped at 5min.
// Integer math on purpose — the old Double-pow formula slept for hours and could overflow
// into a hot loop at extreme attempt counts. Pure and unit-tested.
internal fun retryDelayMs(attempt: Long): Long =
if (attempt >= 4) 300_000L else 30_000L shl attempt.toInt()
}
}
@@ -0,0 +1,7 @@
package eu.darken.capod.common.upgrade.core.billing
import com.android.billingclient.api.Purchase
data class BillingData(
val purchases: Collection<Purchase>
)
@@ -1,11 +1,14 @@
package eu.darken.capod.common.upgrade.core.client
package eu.darken.capod.common.upgrade.core.billing
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
open class BillingException(override val message: String) : Exception(), HasLocalizedError {
open class BillingException(
override val message: String? = null,
override val cause: Throwable? = null,
) : Exception(), HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
@@ -0,0 +1,546 @@
package eu.darken.capod.common.upgrade.core.billing
import android.app.Activity
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
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 eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.billing.client.BillingClientException
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnection
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnectionProvider
import eu.darken.capod.common.upgrade.core.billing.client.redacted
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BillingManager @Inject constructor(
@AppScope private val scope: CoroutineScope,
connectionProvider: BillingConnectionProvider,
) {
// Fresh Play data plus its provenance: a query result covers owned products of the queried
// types, while a purchase event only carries the products of that transaction — consumers
// deciding between per-SKU behaviors (like grace windows) need to know the difference.
data class FreshData(
val data: BillingData,
val isFullSnapshot: Boolean,
// Commit time of the underlying Play round-trip — see BillingConnection.FreshUpdate.occurredAt.
val occurredAt: Long = System.currentTimeMillis(),
)
// Bumped whenever someone actively wants billing NOW (see useConnection): a pending reconnect
// backoff is cut short instead of making the user wait out the timer. A generation counter
// (compared against the value captured at attempt start) instead of an event flow, so demand
// arriving while a connection attempt is still in flight isn't lost, while demand that was
// already satisfied by a healthy connection can't skip a future backoff.
private val connectionDemand = MutableStateFlow(0)
// Highest demand generation whose useConnection call already terminated (served, failed, or
// cancelled): settled demand must not skip a backoff after a later disconnect.
private val servedDemand = MutableStateFlow(0)
// Signalled when an action fails with a response code that means the current connection is
// dead (binder gone) — Play doesn't always deliver onBillingServiceDisconnected, and a dead
// connection must not stay installed for later callers.
private val invalidations = Channel<Unit>(Channel.CONFLATED)
// The currently usable connection, null while (re)connecting. Nulled BEFORE any backoff, so a
// dead connection is unreachable by construction — no replay cache to serve stale clients.
private val connectionHolder = MutableStateFlow<BillingConnection?>(null)
// At least one connect-loop iteration FAILED since process start. Success needs no explicit
// signal: it is implied by billingData emitting (the connection is only published after its
// initial refreshPurchases committed), so settledness travels with the data itself and can't
// lead it. Failures are different — the connect loop swallows them (it retries forever) and
// downstream flows just stay quiet during an outage, so consumers need this explicit signal
// to settle their null seed instead of waiting on a broken connection indefinitely.
private val failedOnce = MutableStateFlow(false)
val isFailureSettled: Flow<Boolean> = failedOnce
// Fires once per failed connect-loop iteration: connection setup failure, the mandatory initial
// refreshPurchases erroring or timing out, an established connection dropping, an action-level
// invalidation (SERVICE_DISCONNECTED/SERVICE_TIMEOUT from any useConnection call), or an
// unexpected provider completion. Every one is a fresh reconciliation that couldn't confirm Pro.
// The connect loop retries these internally and downstream flows just go quiet, so without this
// explicit signal the grace episode clock (UpgradeRepoGplay.proUnconfirmedSince) would only
// advance on an explicit ON_RESUME refresh().
//
// Each value is the failure's OCCURRENCE time (epoch millis). It has to be, not a bare Unit: the
// channel buffers, and this feed and freshBillingData are separate flows with no cross-stream
// ordering, so a failure enqueued before a later retry succeeds could be consumed AFTER that
// success already confirmed Pro and closed the episode. Carrying the failure's own timestamp lets
// the consumer compare it against the last confirmation and drop a superseded one instead of
// reopening a closed episode. UNLIMITED + receiveAsFlow (same idiom as BillingConnection
// .freshUpdates): one lifetime consumer, buffered so a failure that fires before it subscribes
// isn't lost.
private val connectionFailuresChannel = Channel<Long>(Channel.UNLIMITED)
val connectionFailures: Flow<Long> = connectionFailuresChannel.receiveAsFlow()
init {
// The connect loop: owns ALL retry policy. Deliberately NOT wrapped in
// setupCommonEventHandlers — its catch{} swallows cancellations, and this loop must die
// with the scope, not retry through it.
scope.launch {
var failStreak = 0
while (true) {
val demandAtStart = connectionDemand.value
// Drain invalidations from the previous connection's lifetime: a signal referring
// to an already-dead connection must not kill the upcoming attempt. (A racing
// signal between here and the watcher below costs one extra reconnect, nothing
// more.)
while (invalidations.tryReceive().isSuccess) {
// drained
}
try {
coroutineScope {
val invalidationWatcher = launch {
invalidations.receive()
throw BillingException("Billing connection invalidated by a failed action")
}
try {
connectionProvider.connection.collect { connection ->
// A refresh that can't verify anything (nothing found + a query
// failed) throws and counts as a connection failure — otherwise a
// cold start against a broken Play would starve billingData and
// isFailureSettled forever with no retry. withTimeoutOrNull, NOT
// withTimeout: TimeoutCancellationException is a
// CancellationException and would kill this loop.
withTimeoutOrNull(INITIAL_REFRESH_TIMEOUT_MS) {
connection.refreshPurchases()
} ?: throw BillingException("Initial purchase refresh timed out")
failStreak = 0
connectionHolder.value = connection
log(TAG, INFO) { "Billing connection established" }
}
// The provider flow stays open for the connection's lifetime; a normal
// completion means the connection is gone without an error — treat it
// like one so we reconnect (with backoff, no tight loop).
throw BillingException("Billing connection completed unexpectedly")
} finally {
invalidationWatcher.cancel()
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Billing connection failed: ${e.asLog()}" }
// A failed iteration is a fresh reconciliation that couldn't confirm Pro — signal
// it (with its occurrence time) so the entitlement layer can advance the grace
// episode clock even when no explicit refresh() caller is watching. Superseded
// failures are dropped downstream; duplicates are idempotent (set-if-unset).
connectionFailuresChannel.trySend(System.currentTimeMillis())
}
connectionHolder.value = null
// Only reachable via the catch above (the collect never returns normally and
// cancellation rethrows past this) — a genuinely failure-only signal.
failedOnce.value = true
// A swallowed cancellation (e.g. via a flow wrapper) must not convert scope death
// into another connection attempt.
ensureActive()
failStreak++
val backoffMs = if (failStreak >= 5) MAX_BACKOFF_MS else 2_000L shl (2 * (failStreak - 1))
log(TAG) { "Billing reconnect backoff: streak=$failStreak, waiting ${backoffMs}ms" }
// Interruptible backoff: demand that is newer than this attempt AND not yet served
// skips the wait — a user who just fixed their Play situation shouldn't wait out
// the timer. The demandAtStart comparison limits a still-waiting caller to one
// skip per attempt (no tight retry loop).
withTimeoutOrNull(backoffMs) {
connectionDemand.first { it != demandAtStart && it > servedDemand.value }
}
}
}
}
// Re-drives the ack pass WITHOUT a new purchases emission: `purchases` is distinctUntilChanged,
// so a refresh returning a byte-identical (still unacknowledged) list is deduped and could never
// retry a failed ack -- the pipeline starved until Play sent something different. Declared ahead
// of `purchases` because that chain's failure recovery signals it.
private val ackRetryTrigger = MutableStateFlow(0)
// Never-terminal resubscribe for the hot billing sources: a shared flow has no owner to restart
// it, so an upstream failure would kill the sharing coroutine and leave every subscriber (incl.
// the ack collector) hanging for the process lifetime.
private fun <T> Flow<T>.resubscribeOnFailure(label: String, onRecover: () -> Unit = {}): Flow<T> =
retryWhen { cause, _ ->
if (cause is CancellationException) return@retryWhen false
log(TAG, ERROR) { "$label failed, resubscribing: ${cause.asLog()}" }
delay(SHARE_RETRY_MS)
onRecover()
true
}
private val purchases = connectionHolder
// NOT filterNotNull(): the null emission is what detaches a dead connection's inner flows.
.flatMapLatest { connection ->
(connection?.purchases ?: emptyFlow())
// The retry sits INSIDE the flatMapLatest, and that placement is load-bearing:
// flatMapLatest hands values to its downstream across a channel, and a failure of
// the inner flow cancels the coroutine that drains it -- a value emitted just
// before the failure is then discarded instead of delivered. Retrying out here
// keeps the failure off that boundary, so the last pre-failure emission (e.g. the
// purchase that still needs acknowledging) survives the outage.
.resubscribeOnFailure("purchases") {
// The resubscribed source replays a byte-identical list, which the
// distinctUntilChanged below rightly drops -- so recovery has to nudge the ack
// pass explicitly, or a purchase that arrived just before the failure would sit
// there until the 5-minute reschedule.
ackRetryTrigger.update { it + 1 }
}
}
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "purchases" }
// Belt for the plumbing outside the source flow itself: connectionHolder is a state holder
// that never throws, so this should never fire -- but a dead sharing coroutine is
// unrecoverable, and that is not a risk worth leaving open. No nudge needed here: this
// resubscribes distinctUntilChanged too, so the replayed list gets through on its own.
.resubscribeOnFailure("purchases-share")
.shareIn(scope, WhileSubscribed(3000L, 0L), replay = 1)
val billingData: Flow<BillingData> = purchases
.map { BillingData(purchases = it) }
.shareIn(scope, WhileSubscribed(3000L, 0L), replay = 1)
val purchaseFailures: Flow<BillingResult> = connectionHolder
.flatMapLatest { it?.purchaseFailures ?: emptyFlow() }
.setupCommonEventHandlers(TAG) { "purchaseFailures" }
// Only data that was *freshly* obtained from Play, in the connection's COMMIT ORDER: query
// results and completed purchase events, emitted by the reducer itself. Unlike billingData
// (whose shareIn replay re-serves old data to late subscribers), every emission here
// represents an actual Play round-trip, so consumers can safely use it for time-based
// bookkeeping like the Pro grace period. Eagerly: the per-connection channel has exactly one
// consumer — this chain — which must not depend on downstream subscribers.
val freshBillingData: Flow<FreshData> = connectionHolder
.flatMapLatest { connection ->
(connection?.freshUpdates ?: emptyFlow())
// Inside the flatMapLatest for the same reason as `purchases`: a failure escaping
// the boundary discards the update that was already handed to the channel -- and
// every emission here is a real Play round-trip the grace bookkeeping needs.
.resubscribeOnFailure("freshBillingData")
}
.map { FreshData(data = BillingData(purchases = it.purchases), isFullSnapshot = it.isFullSnapshot, occurredAt = it.occurredAt) }
.setupCommonEventHandlers(TAG) { "freshBillingData" }
// Same belt as `purchases`: an Eagerly shared flow that dies stays dead, and this one feeds
// both the grace bookkeeping and the ack collector's re-drive signal.
.resubscribeOnFailure("freshBillingData-share")
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
// Tokens we've already SUCCESSFULLY acknowledged this process. LOG-LEVEL HINT ONLY: it selects
// INFO (first ack) vs DEBUG (idempotent repeat) and MUST NOT gate the acknowledgePurchase call
// below. The immutable Purchase snapshot keeps reporting isAcknowledged=false until a fresh Play
// query supersedes it, so the ack re-fires every emission until then; re-acking is a documented
// no-op on Play's side, whereas skipping a needed ack gets the purchase auto-refunded after 3
// days -- so the ack stays unconditional and this set only quiets the log spam. Single
// sequential collector (the ack pass below), no locking needed.
private val loggedAckTokens = mutableSetOf<String>()
// Tokens whose PERMANENT ack failure was already reported. Play will keep rejecting these
// (developer error, item not owned, unsupported feature), so the bug report fires once per token
// instead of once per pass. Same single-collector confinement as loggedAckTokens.
private val reportedAckFailures = mutableSetOf<String>()
// At most one reschedule timer in flight: repeated failures must not stack timers.
private val ackRetryPending = MutableStateFlow(false)
init {
combine(
// The canonical list is the ONLY data slot. The other side is signal-only, so a partial
// (e.g. SUBS-only) fresh emission can never become the retried set.
purchases,
merge(freshBillingData.map { }, ackRetryTrigger.map { }),
) { currentPurchases, _ -> currentPurchases }
.onEach { runAckPass(it) }
.setupCommonEventHandlers(TAG) { "connection-acks" }
// Never-dying belt: runAckPass only lets CancellationException escape, so this catches
// flow-plumbing failures only -- the collector must live for the whole process.
.retryWhen { cause, _ ->
if (cause is CancellationException) {
log(TAG) { "Ack collector was cancelled (appScope died)" }
return@retryWhen false
}
log(TAG, ERROR) { "Ack collector failed, restarting: ${cause.asLog()}" }
delay(ACK_CHAIN_RETRY_MS)
true
}
.launchIn(scope)
}
// Per-purchase acknowledgement result. Derived ONLY from the acknowledgePurchase call (success =
// returned without throwing; it throws on non-OK) -- NEVER from re-reading Purchase
// .isAcknowledged, whose immutable snapshot stays false until a fresh Play query.
private enum class AckOutcome { SUCCESS, TRANSIENT, PERMANENT }
// One acknowledgement pass over the canonical purchase list. Never throws except cancellation:
// transient failures schedule a re-drive, permanent ones are reported and left to organic fresh
// -data signals.
private suspend fun runAckPass(purchases: Collection<Purchase>) {
val needAck = purchases.filter {
val needsAck = !it.isAcknowledged
if (needsAck) log(TAG) { "Needs ACK: ${it.redacted()}" }
else log(TAG) { "Already ACK'ed: ${it.redacted()}" }
needsAck
}
var transientFailures = 0
for (purchase in needAck) {
// First ack of a token is INFO; idempotent repeats drop to DEBUG. This never gates the
// ack -- acknowledgePurchase runs regardless of set membership.
val ackPriority = if (purchase.purchaseToken in loggedAckTokens) DEBUG else INFO
log(TAG, ackPriority) { "Acknowledging purchase: ${purchase.redacted()}" }
var outcome = AckOutcome.TRANSIENT
var abortPass = false
for (attempt in 1..ACK_MAX_ATTEMPTS) {
outcome = try {
// Bounded: useConnection waits for a connection indefinitely, so without this an
// outage would park the pass (and every later retry) forever. A null result is a
// failed TRANSIENT attempt -- either the wait or the ack itself ran out of time.
val acked = withTimeoutOrNull(ACK_CONNECTION_TIMEOUT_MS) {
useConnection { acknowledgePurchase(purchase) }
}
if (acked != null) {
AckOutcome.SUCCESS
} else {
log(TAG, WARN) { "Ack attempt $attempt timed out: ${purchase.redacted()}" }
AckOutcome.TRANSIENT
}
} catch (e: CancellationException) {
// AppScope death is not an acknowledgement failure: no reschedule, no retries.
throw e
} catch (e: Exception) {
val code = (e as? BillingClientException)?.result?.responseCode
when {
code == BillingResponseCode.BILLING_UNAVAILABLE -> {
// Connection-level condition: every other purchase in this pass would
// fail the same way. The reschedule keeps re-attempting later -- unlike
// before, this no longer permanently kills the ack retries.
log(TAG, WARN) { "BILLING_UNAVAILABLE, aborting ack pass:\n${e.asLog()}" }
abortPass = true
AckOutcome.TRANSIENT
}
(code != null && code in PERMANENT_ACK_CODES) || e !is BillingException -> {
reportPermanentAckFailure(purchase, e)
AckOutcome.PERMANENT
}
else -> {
log(TAG, WARN) { "Ack attempt $attempt failed: ${purchase.redacted()}\n${e.asLog()}" }
AckOutcome.TRANSIENT
}
}
}
if (outcome == AckOutcome.SUCCESS) {
// Only after a *successful* ack: a failed one never lands here, so it stays loud
// and retryable.
loggedAckTokens.add(purchase.purchaseToken)
break
}
if (outcome == AckOutcome.PERMANENT || abortPass) break
// 3s, 6s -- `attempt` starts at 1, deliberately no zero-delay first retry.
if (attempt < ACK_MAX_ATTEMPTS) delay(ACK_RETRY_DELAY_MS * attempt)
}
if (outcome == AckOutcome.TRANSIENT) transientFailures++
if (abortPass) break
}
// Permanent failures never arm the timer -- only organic fresh-data signals re-attempt them.
if (transientFailures > 0) {
log(TAG, ERROR) {
"$transientFailures purchase(s) left unacknowledged, re-driving in ${ACK_RESCHEDULE_MS}ms"
}
scheduleAckRetry()
}
}
// A purchase Play will keep rejecting: report it once per token, then stay quiet. The pass still
// re-attempts it whenever fresh Play data arrives -- the ack is never skipped, only the noise is.
private fun reportPermanentAckFailure(purchase: Purchase, error: Exception) {
if (reportedAckFailures.add(purchase.purchaseToken)) {
log(TAG, ERROR) { "Permanent ack failure for ${purchase.redacted()}:\n${error.asLog()}" }
Bugs.report(TAG, "Failed to acknowledge purchase", error)
} else {
log(TAG, WARN) { "Permanent ack failure (already reported) for ${purchase.redacted()}" }
}
}
// Arms the re-drive timer at most once: further failures while it is pending join the same
// scheduled pass instead of stacking timers.
private fun scheduleAckRetry() {
if (!ackRetryPending.compareAndSet(expect = false, update = true)) {
log(TAG) { "Ack retry already scheduled" }
return
}
scope.launch {
delay(ACK_RESCHEDULE_MS)
ackRetryPending.value = false
ackRetryTrigger.update { it + 1 }
}
}
private suspend fun <T> useConnection(action: suspend BillingConnection.() -> T): T {
// Every caller here is active demand (opening the upgrade screen, restore/buy taps,
// purchase acks) — cut a pending reconnect backoff short. A no-op while healthy.
val demandGen = connectionDemand.updateAndGet { it + 1 }
var used: BillingConnection? = null
try {
val connection = connectionHolder.filterNotNull().first().also { used = it }
return connection.action()
} catch (e: Exception) {
// These codes mean the binder is gone. Play doesn't reliably deliver
// onBillingServiceDisconnected for them, so uninstall the dead connection RIGHT HERE
// (the loop's teardown takes several dispatches — later callers must not grab the
// stale holder in that window) and tell the connect loop to clean up and reconnect.
// The failure may arrive user-friendly-mapped (e.g. GplayServiceUnavailableException
// from a refresh), so inspect the cause chain, not just the exception itself.
// CAS + identity check: a fresh replacement must not be killed for its predecessor's
// failure.
val clientError = (e as? BillingClientException) ?: (e.cause as? BillingClientException)
if (clientError != null && clientError.result.responseCode in INVALIDATING_CODES && used != null) {
if (connectionHolder.compareAndSet(used, null)) {
log(TAG, WARN) { "Connection reported dead by action (${clientError.result.responseCode}), invalidating." }
invalidations.trySend(Unit)
}
}
throw e
} finally {
// Settled on ANY termination — success, error, or the caller's own timeout/cancel: a
// call that is over is no longer pending demand and must not skip a later backoff.
servedDemand.update { served -> maxOf(served, demandGen) }
}
}
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> = useConnection {
log(TAG) { "querySkus(): $skus..." }
querySkus(*skus).also {
log(TAG) { "querySkus(): $it" }
}
}
suspend fun startIapFlow(activity: Activity, sku: Sku, offer: Sku.Subscription.Offer?) {
try {
useConnection {
launchBillingFlow(activity, sku, offer)
}
} catch (e: CancellationException) {
// Not an error: routing this into Bugs.report (or mapping it) would fake telemetry
// and break structured cancellation.
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" }
// Expected environmental/user situations — user-facing handling only, no bug report.
// ITEM_ALREADY_OWNED is auto-handled by UpgradeRepoGplay (restore instead of error).
val ignoredCodes = listOf(
BillingResponseCode.USER_CANCELED,
BillingResponseCode.BILLING_UNAVAILABLE,
BillingResponseCode.ERROR,
BillingResponseCode.ITEM_ALREADY_OWNED,
)
when {
e !is BillingException -> {
Bugs.report(TAG, "State exception for $sku, U", e)
}
e is BillingClientException && !e.result.responseCode.let { ignoredCodes.contains(it) } -> {
Bugs.report(TAG, "Client exception for $sku", e)
}
}
throw e.tryMapUserFriendly()
}
}
suspend fun refresh(): BillingData {
log(TAG) { "refresh()" }
// Query in the caller's context and return the result directly, so callers get the fresh
// purchases (and any billing error) with a real happens-before instead of racing the
// shared upgradeInfo replay cache. The freshBillingData emission happens inside the
// reducer's commit, in commit order — not here.
val fresh = useConnection { refreshPurchases() }
return BillingData(purchases = fresh.purchases)
}
// Strict SUBS-only query for the pre-purchase subscription gate: unlike refresh(), a failure
// here propagates (user-friendly-mapped) instead of being masked by the other product type.
suspend fun querySubscriptions(): Collection<Purchase> = try {
useConnection { querySubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "querySubscriptions() failed: ${e.asLog()}" }
throw e.tryMapUserFriendly()
}
companion object {
internal fun Throwable.tryMapUserFriendly(): Throwable {
if (this !is BillingClientException) return this
return when (result.responseCode) {
BillingResponseCode.USER_CANCELED -> UserCanceledBillingException(this)
BillingResponseCode.BILLING_UNAVAILABLE,
BillingResponseCode.SERVICE_UNAVAILABLE,
BillingResponseCode.SERVICE_DISCONNECTED,
BillingResponseCode.SERVICE_TIMEOUT -> GplayServiceUnavailableException(this)
BillingResponseCode.ERROR -> InternalBillingException(this)
BillingResponseCode.NETWORK_ERROR -> NetworkBillingException(this)
BillingResponseCode.ITEM_ALREADY_OWNED -> ItemAlreadyOwnedBillingException(this)
else -> this
}
}
@Suppress("DEPRECATION")
private val INVALIDATING_CODES = setOf(
BillingResponseCode.SERVICE_DISCONNECTED,
BillingResponseCode.SERVICE_TIMEOUT,
)
// Ack failures Play won't stop reporting no matter how often we retry: a retry loop would
// just burn battery and log noise. Everything else (service down, network, timeouts,
// unmapped codes) is treated as transient.
private val PERMANENT_ACK_CODES = setOf(
BillingResponseCode.DEVELOPER_ERROR,
BillingResponseCode.FEATURE_NOT_SUPPORTED,
BillingResponseCode.ITEM_NOT_OWNED,
)
private const val INITIAL_REFRESH_TIMEOUT_MS = 30_000L
private const val MAX_BACKOFF_MS = 300_000L
// Inline attempts per purchase and their backoff (3s, 6s): covers a short Play hiccup
// without leaving an unacknowledged purchase near its 3-day auto-refund deadline.
private const val ACK_MAX_ATTEMPTS = 3
private const val ACK_RETRY_DELAY_MS = 3_000L
// Whole-pass re-drive after the inline attempts couldn't finish: the purchase list itself is
// deduped, so this timer is what keeps a failed ack alive across a longer outage.
private const val ACK_RESCHEDULE_MS = 300_000L
// Bounds the (otherwise unbounded) connection wait plus ack round-trip of one attempt.
private const val ACK_CONNECTION_TIMEOUT_MS = 30_000L
// Restart delay for the ack collector itself and for the hot shared sources it feeds on.
private const val ACK_CHAIN_RETRY_MS = 60_000L
private const val SHARE_RETRY_MS = 60_000L
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Manager")
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.common.upgrade.core.billing
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
class GplayServiceUnavailableException(cause: Throwable) :
BillingException("Google Play services are unavailable.", cause), HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = context.getString(R.string.upgrades_gplay_unavailable_error),
description = context.getString(R.string.upgrades_gplay_unavailable_error_description),
)
}
@@ -0,0 +1,16 @@
package eu.darken.capod.common.upgrade.core.billing
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
class InternalBillingException(cause: Throwable) :
BillingException("An internal Google Play error occurred.", cause), HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = context.getString(R.string.upgrades_gplay_internal_error_title),
description = context.getString(R.string.upgrades_gplay_internal_error_description),
)
}
@@ -1,18 +1,16 @@
package eu.darken.capod.common.upgrade.core.client
package eu.darken.capod.common.upgrade.core.billing
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
// Google Play reports the product as already owned when trying to launch the purchase flow.
// UpgradeRepoGplay auto-handles this by restoring; this error only surfaces if that fails.
class ItemAlreadyOwnedBillingException(cause: Throwable) :
Exception("Already owned according to Google Play.", cause), HasLocalizedError {
BillingException("Item is already owned.", cause), HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = context.getString(R.string.upgrades_gplay_already_owned_label),
description = context.getString(R.string.upgrades_gplay_already_owned_description)
description = context.getString(R.string.upgrades_gplay_already_owned_description),
)
}
@@ -1,15 +1,16 @@
package eu.darken.capod.common.upgrade.core.client
package eu.darken.capod.common.upgrade.core.billing
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
class GplayServiceUnavailableException(cause: Throwable) : Exception("Google Play services are unavailable.", cause),
HasLocalizedError {
class NetworkBillingException(cause: Throwable) :
BillingException("Unable to connect to Google Play.", cause), HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = "Google Play Services Unavailable",
description = context.getString(R.string.upgrades_gplay_unavailable_error)
label = context.getString(R.string.upgrades_gplay_network_error_title),
description = context.getString(R.string.upgrades_gplay_network_error_description),
)
}
@@ -0,0 +1,28 @@
package eu.darken.capod.common.upgrade.core.billing
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
/**
* Play can't sell us this product/offer right now: it was omitted from the product-details response,
* came back ambiguous (duplicate rows), or is reported as unavailable.
*
* This is a merchandising state (region, account eligibility, a withheld or revoked offer), NOT a
* defect on our side — so it must stay off the bug-report path and surface as user-facing copy
* instead of the raw NoSuchElementException/NPE that the strict `single`/`!!` lookups produced.
*/
class OfferUnavailableBillingException(
val sku: Sku,
val offer: Sku.Subscription.Offer?,
) : BillingException(
"Google Play has no usable offer for ${sku.print()} (offer=${offer?.let { "${it.basePlanId}/${it.offerId}" }})",
), HasLocalizedError {
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = context.getString(R.string.upgrades_gplay_offer_unavailable_title),
description = context.getString(R.string.upgrades_gplay_offer_unavailable_description),
)
}
@@ -0,0 +1,11 @@
package eu.darken.capod.common.upgrade.core.billing
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.billing.client.redacted
data class PurchasedSku(val sku: Sku, val purchase: Purchase) {
// Purchase.skus is deprecated (superseded by products); redacted() is the log-safe renderer used
// everywhere else on this path — it adds the diagnostic fields (state, ack, renewal) while
// keeping purchase token and order ID out of debug recordings.
override fun toString(): String = "PurchasedSku(sku=$sku, purchase=${purchase.redacted()})"
}
@@ -0,0 +1,40 @@
package eu.darken.capod.common.upgrade.core.billing
import com.android.billingclient.api.ProductDetails
interface Sku {
val id: String
val type: Type
fun print(): String = "Sku(id=$id, type=$type)"
interface Iap : Sku {
override val id: String
override val type: Type
get() = Type.IAP
}
interface Subscription : Sku {
override val id: String
override val type: Type
get() = Type.SUBSCRIPTION
val offers: Collection<Offer>
interface Offer {
val basePlanId: String
val offerId: String?
fun matches(target: ProductDetails.SubscriptionOfferDetails): Boolean {
return basePlanId == target.basePlanId && offerId == target.offerId
}
}
}
enum class Type {
IAP,
SUBSCRIPTION,
;
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.common.upgrade.core.data
package eu.darken.capod.common.upgrade.core.billing
import com.android.billingclient.api.ProductDetails
@@ -0,0 +1,8 @@
package eu.darken.capod.common.upgrade.core.billing
/**
* Exception thrown when user cancels the billing flow.
* Does NOT implement HasLocalizedError - should be dismissed silently.
*/
class UserCanceledBillingException(cause: Throwable) :
BillingException("User canceled billing flow.", cause)
@@ -0,0 +1,10 @@
package eu.darken.capod.common.upgrade.core.billing.client
import com.android.billingclient.api.BillingResult
import eu.darken.capod.common.upgrade.core.billing.BillingException
class BillingClientException(val result: BillingResult) : BillingException(result.debugMessage) {
override fun toString(): String =
"BillingClientException(code=${result.responseCode}, message=${result.debugMessage})"
}
@@ -0,0 +1,31 @@
package eu.darken.capod.common.upgrade.core.billing.client
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
internal val BillingResult.isSuccess: Boolean
get() = responseCode == BillingClient.BillingResponseCode.OK
/**
* Log-safe rendering of a [Purchase].
*
* `Purchase.toString()` dumps the original response JSON, which carries the purchase token and the
* order ID. Debug recordings are attached to support emails by users, so anything logged here ends
* up in an inbox and wherever the user forwarded it. Everything actually useful for diagnosing an
* entitlement problem is non-identifying, so log only that.
*
* Total by construction: this runs inside `log {}` lambdas, which evaluate on the billing path
* whenever a recording is active. A formatter that can throw there would replace a real billing
* result (or a real billing exception) with a diagnostics failure, which is strictly worse than a
* degraded log line.
*/
internal fun Purchase.redacted(): String = runCatching {
"Purchase(products=$products, state=$purchaseState, acknowledged=$isAcknowledged, " +
"autoRenewing=$isAutoRenewing, purchaseTime=$purchaseTime)"
}.getOrElse { "Purchase(unreadable: ${it::class.simpleName})" }
internal fun Collection<Purchase>.redacted(): String = runCatching {
joinToString(prefix = "[", postfix = "]") { it.redacted() }
}.getOrElse { "[unreadable: ${it::class.simpleName}]" }
@@ -0,0 +1,551 @@
package eu.darken.capod.common.upgrade.core.billing.client
import android.app.Activity
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryProductDetailsResult
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.UnfetchedProduct
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
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.core.OurSku
import eu.darken.capod.common.upgrade.core.billing.BillingManager.Companion.tryMapUserFriendly
import eu.darken.capod.common.upgrade.core.billing.OfferUnavailableBillingException
import eu.darken.capod.common.upgrade.core.billing.Sku
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class BillingConnection(
private val client: BillingClient,
private val skuTypeOf: (String) -> Sku.Type? = DEFAULT_SKU_TYPE_RESOLVER,
) {
// A purchase proven by an onPurchasesUpdated success event. Additive only: events prove
// ownership, never absence. `gen` orders it against queries (a query that STARTED before this
// event must not clear it); `type` is resolved at ingestion so a later per-type query that
// confirms absence can supersede it (null = product unknown to this app, only a complete
// refresh may clear it).
data class OverlayEntry(
val purchase: Purchase,
val gen: Long,
val type: Sku.Type?,
)
// The single, atomically-updated ownership state of this connection. Split state (per-type
// caches, separate event flows) exposed intermediate combinations and starved on partial
// failures — every mutation here is a pure copy applied under `reducerLock`, so observers only
// ever see committed states and refreshPurchases() can return the exact state it committed.
data class ReducerState(
val iapSnapshot: Collection<Purchase>? = null,
val subSnapshot: Collection<Purchase>? = null,
val overlay: List<OverlayEntry> = emptyList(),
val eventGen: Long = 0L,
) {
internal fun withEvent(
purchased: Collection<Purchase>,
typeOf: (String) -> Sku.Type?,
): ReducerState {
val gen = eventGen + 1
val entries = purchased.map { purchase ->
OverlayEntry(
purchase = purchase,
gen = gen,
type = purchase.products.firstNotNullOfOrNull(typeOf),
)
}
return copy(eventGen = gen, overlay = overlay + entries)
}
internal fun withQueryResults(
iap: Collection<Purchase>?,
sub: Collection<Purchase>?,
genAtQueryStart: Long,
): ReducerState {
val clearedTypes = setOfNotNull(
Sku.Type.IAP.takeIf { iap != null },
Sku.Type.SUBSCRIPTION.takeIf { sub != null },
)
val isComplete = clearedTypes.size == 2
return copy(
iapSnapshot = iap ?: iapSnapshot,
subSnapshot = sub ?: subSnapshot,
// A successful per-type query is authoritative for that type: overlay entries it
// could have seen (gen <= start) are superseded by its result. Entries of a FAILED
// type survive, as do events that arrived after the query started. Untyped entries
// (unknown product) only fall to a complete refresh.
overlay = overlay.filterNot { entry ->
entry.gen <= genAtQueryStart &&
(entry.type in clearedTypes || (isComplete && entry.type == null))
},
)
}
// Never verified anything this connection: downstream must not mistake "don't know yet"
// for "owns nothing".
internal val isSettled: Boolean
get() = iapSnapshot != null || subSnapshot != null
// Snapshots first, overlay overwrites: a surviving overlay entry is by construction newer
// than the last successful query of its type (older ones were cleared), so its purchase
// data (ack state etc.) is fresher. Dedup by purchaseToken (the stable purchase identity):
// snapshot and overlay instances of the same purchase can differ in ack state, so Purchase
// equality or object identity would retain both instead of letting the newer one win.
internal fun merged(): Collection<Purchase> {
val byToken = LinkedHashMap<String, Purchase>()
iapSnapshot.orEmpty().forEach { byToken[it.purchaseToken] = it }
subSnapshot.orEmpty().forEach { byToken[it.purchaseToken] = it }
overlay.forEach { byToken[it.purchase.purchaseToken] = it.purchase }
return byToken.values.sortedByDescending { it.purchaseTime }
}
}
// Fresh data straight from a Play round-trip, in COMMIT ORDER: emitted under the same lock
// that mutates the reducer state, so a consumer can never observe a purchase event AFTER the
// query commit that superseded it (or a stale snapshot after a newer event). Query emissions
// carry only what the queries confirmed — never retained stale data — because consumers use
// this for time-based bookkeeping like the Pro grace period.
data class FreshUpdate(
val purchases: Collection<Purchase>,
val isFullSnapshot: Boolean,
// Wall-clock time this update was COMMITTED under reducerLock — i.e. when Play actually
// confirmed this data. Defaults to construction time, which at every production call site is
// the commit instant (this type is only ever built inside the reducer commit below). The Pro
// entitlement layer stamps its grace anchor with this so a confirmation and a later
// connection failure are ordered by when they HAPPENED, not by when each separate flow got
// around to processing them — see UpgradeRepoGplay.recordProState / BillingCache
// .stampLastProState.
val occurredAt: Long = System.currentTimeMillis(),
)
// Guards state mutation + fresh emission as one atomic step. Kept a plain monitor (not a
// Mutex): the listener path is synchronous on Play's callback thread.
private val reducerLock = Any()
private val state = MutableStateFlow(ReducerState())
// UNLIMITED: event volume is tiny and a silently dropped item would lose a grace stamp or an
// already-owned recovery. Closed by the provider when the connection dies.
private val freshUpdatesChannel = Channel<FreshUpdate>(Channel.UNLIMITED)
private val failureChannel = Channel<BillingResult>(Channel.UNLIMITED)
val freshUpdates: Flow<FreshUpdate> = freshUpdatesChannel.receiveAsFlow()
// Non-OK results from onPurchasesUpdated (e.g. async ITEM_ALREADY_OWNED after the Play sheet
// opened). A channel, not state: events must not conflate, and a late subscriber must not be
// served a stale failure. Consumed by a single persistent collector chain.
val purchaseFailures: Flow<BillingResult> = failureChannel.receiveAsFlow()
val purchases: Flow<Collection<Purchase>> = state
.mapNotNull { current -> current.takeIf { it.isSettled }?.merged() }
.setupCommonEventHandlers(TAG) { "purchases" }
// Called synchronously from the PurchasesUpdatedListener on Play's callback thread:
// exactly-once per callback, ordered, and atomic with the fresh emission. Success and failure
// results stay strictly apart — a failure (reopened sheet -> USER_CANCELED) must not evict a
// fresh purchase event.
internal fun onPurchasesUpdated(result: BillingResult, purchases: Collection<Purchase>?) {
if (result.isSuccess) {
log(TAG) {
"onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, " +
"purchases=${purchases?.redacted()})"
}
// PENDING purchases must never surface as owned (or stamp the Pro grace cache).
val purchased = purchases.orEmpty().filter { it.purchaseState == PurchaseState.PURCHASED }
synchronized(reducerLock) {
state.value = state.value.withEvent(purchased, skuTypeOf)
if (purchased.isNotEmpty()) {
freshUpdatesChannel.trySend(FreshUpdate(purchased, isFullSnapshot = false))
}
}
} else {
log(TAG, WARN) {
"error: onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, " +
"purchases=${purchases?.redacted()})"
}
failureChannel.trySend(result)
}
}
// Called by the provider when this connection ends: completes the event flows so consumers
// don't wait on a dead connection's channels.
internal fun close() {
freshUpdatesChannel.close()
failureChannel.close()
}
// The purchases of a refresh plus whether it covered both product types: a partial result (one
// query failed) is still authoritative for what it FOUND, but must not be treated as proof of
// absence for the type that couldn't be checked.
data class PurchaseRefresh(
val purchases: Collection<Purchase>,
val isComplete: Boolean,
)
// Serializes concurrent refreshes (manual, background, auto-restore): an older query that got
// descheduled after Play answered must not commit over a newer one's result.
private val refreshMutex = Mutex()
// Queries both product types and commits the result into the reducer state in ONE atomic
// update, then returns the merged view of exactly that committed state — so the reactive
// purchases flow and this return value can never disagree. Tolerant of a single product-type
// failure: found purchases are authoritative, and an error only propagates when nothing was
// found AND a query failed, so the caller can tell "not owned" apart from "couldn't verify".
suspend fun refreshPurchases(): PurchaseRefresh = refreshMutex.withLock {
coroutineScope {
log(TAG) { "refreshPurchases()" }
val genAtQueryStart = state.value.eventGen
val iapJob = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
val subJob = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
val iap = iapJob.await()
val sub = subJob.await()
log(TAG) { "Refreshed IAPs=${iap.getOrNull()?.redacted()}, SUBs=${sub.getOrNull()?.redacted()}" }
// Commit BEFORE the couldn't-verify error check: a successful per-type result is
// authoritative even when its sibling failed — verified absence (e.g. a refunded IAP)
// must not be discarded just because the SUB query errored.
val isComplete = iap.isSuccess && sub.isSuccess
val committed = synchronized(reducerLock) {
val next = state.value.withQueryResults(
iap = iap.getOrNull(),
sub = sub.getOrNull(),
genAtQueryStart = genAtQueryStart,
)
state.value = next
if (iap.isSuccess || sub.isSuccess) {
// Only what the queries CONFIRMED — retained stale data of a failed type stays
// out of the fresh stream (it would keep re-stamping the grace window).
val confirmed = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty())
.sortedByDescending { it.purchaseTime }
// A surviving overlay entry (purchase event newer than the query start, or of
// a failed type) means this result does NOT prove total absence: it must not
// count as a full snapshot, or an empty query racing a fresh purchase event
// would start a false unconfirmed-grace episode.
val provesAbsence = isComplete && next.overlay.isEmpty()
freshUpdatesChannel.trySend(FreshUpdate(confirmed, isFullSnapshot = provesAbsence))
}
next
}
// Support-log anchor, at INFO because purchase complaints arrive as debug recordings.
// Logs what these queries CONFIRMED, kept distinct from the committed view: merged()
// retains a failed type's previous purchases, so reporting it as "what Play returned"
// would be the same false-certainty trap the copy elsewhere had to fix. Product IDs
// only -- never the Purchase, which carries order and token data.
log(TAG, INFO) {
val confirmedIds = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()).flatMap { it.products }
"refreshPurchases(): confirmed=$confirmedIds, isComplete=$isComplete, " +
"iapOk=${iap.isSuccess}, subOk=${sub.isSuccess}, merged=${committed.merged().size}"
}
// Throws when nothing was found and a query failed, so the caller can tell "not
// owned" apart from "couldn't verify".
combinePurchaseResults(iap, sub)
PurchaseRefresh(
purchases = committed.merged(),
isComplete = isComplete,
)
}
}
// Never throws except on cancellation, so a single failing product-type query doesn't cancel
// the sibling query (or the coroutineScope). The exception is already user-friendly-mapped.
private suspend fun queryPurchasedProducts(
@BillingClient.ProductType type: String,
): Result<Collection<Purchase>> = try {
Result.success(queryPurchases(type).filter { it.purchaseState == PurchaseState.PURCHASED })
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e.tryMapUserFriendly())
}
private suspend fun queryPurchases(@BillingClient.ProductType type: String): Collection<Purchase> {
val params = QueryPurchasesParams.newBuilder().apply {
setProductType(type)
}.build()
// Own cancellable wrapper instead of the billing-ktx extension: a non-cancellable
// suspension would make the timeouts around refreshes hang until Play's callback fires.
// The onCancellation overload makes a callback racing the cancellation a no-op instead of
// an IllegalStateException on Play's thread.
val (billingResult, purchaseData) = suspendCancellableCoroutine<Pair<BillingResult, List<Purchase>>> { continuation ->
client.queryPurchasesAsync(params) { result, purchases ->
if (continuation.isActive) continuation.resume(result to purchases) { _, _, _ -> }
}
}
log(TAG) {
"queryPurchases($type): code=${billingResult.isSuccess}, message=${billingResult.debugMessage}, " +
"purchaseData=${purchaseData.redacted()}"
}
if (!billingResult.isSuccess) {
log(TAG, WARN) { "queryPurchases() failed" }
throw BillingClientException(billingResult)
}
return purchaseData
}
// Strict SUBS-only query for the pre-purchase subscription gate: unlike refreshPurchases(),
// a failure propagates (no cross-type tolerance) — callers must be able to fail closed on
// "couldn't verify". Commits through the reducer like any query, so the reactive purchases
// flow picks up the fresh renewal state, and emits a partial fresh update: it proves what the
// SUBS query found, never the absence of anything it didn't cover.
suspend fun querySubscriptions(): Collection<Purchase> = refreshMutex.withLock {
log(TAG) { "querySubscriptions()" }
val genAtQueryStart = state.value.eventGen
val subs = queryPurchases(BillingClient.ProductType.SUBS)
.filter { it.purchaseState == PurchaseState.PURCHASED }
val committed = synchronized(reducerLock) {
val next = state.value.withQueryResults(
iap = null,
sub = subs,
genAtQueryStart = genAtQueryStart,
)
state.value = next
freshUpdatesChannel.trySend(FreshUpdate(subs, isFullSnapshot = false))
next
}
// The COMMITTED view, not the raw response: a purchase event that arrived after the query
// started survives the commit as a newer overlay and must reach the gate too — otherwise a
// just-purchased renewing sub could slip past the fail-closed double-billing check.
// Non-IAP overlays only; untyped (unknown product) entries stay in on the safe side.
val byToken = LinkedHashMap<String, Purchase>()
subs.forEach { byToken[it.purchaseToken] = it }
committed.overlay
.filter { it.type != Sku.Type.IAP }
.forEach { byToken[it.purchase.purchaseToken] = it.purchase }
byToken.values.sortedByDescending { it.purchaseTime }
}
suspend fun acknowledgePurchase(purchase: Purchase): BillingResult {
val ack = AcknowledgePurchaseParams.newBuilder().apply {
setPurchaseToken(purchase.purchaseToken)
}.build()
val ackResult = suspendCancellableCoroutine<BillingResult> { continuation ->
client.acknowledgePurchase(ack) {
if (continuation.isActive) continuation.resume(it) { _, _, _ -> }
}
}
log(TAG) {
"acknowledgePurchase(purchase=${purchase.redacted()}): code=${ackResult.responseCode}, " +
"message=${ackResult.debugMessage})"
}
if (!ackResult.isSuccess) {
throw BillingClientException(ackResult)
}
return ackResult
}
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> {
log(TAG) { "querySkus(skus=${skus.joinToString { it.print() }})..." }
// Play answers per product, not per request entry: a duplicated request entry would make the
// exactly-one-match rule below ambiguous for no reason.
val requested = skus.distinctBy { it.id to it.type }
if (requested.size != skus.size) {
log(TAG, WARN) { "querySkus(): deduped duplicate request entries: ${skus.joinToString { it.print() }}" }
}
val productList = requested.map { sku ->
QueryProductDetailsParams.Product.newBuilder().apply {
setProductId(sku.id)
setProductType(sku.playProductType)
}.build()
}
val params = QueryProductDetailsParams.newBuilder().apply {
setProductList(productList)
}.build()
// Cancellable so the ViewModel's query timeout and flatMapLatest-based retry actually work:
// with suspendCoroutine a missing Play callback kept the timeout suspended indefinitely.
val (result, queryResult) = suspendCancellableCoroutine<Pair<BillingResult, QueryProductDetailsResult>> { continuation ->
client.queryProductDetailsAsync(params) { result, queryResult ->
if (continuation.isActive) {
continuation.resume(result to queryResult) { _, _, _ -> }
}
}
}
val details = queryResult.productDetailsList.orEmpty()
val unfetched = queryResult.unfetchedProductList.orEmpty()
log(TAG) {
"querySkus(skus=${skus.joinToString { it.print() }}): code=${result.responseCode}, " +
"debug=${result.debugMessage}, skuDetails=$details, unfetched=${unfetched.printed()}"
}
if (!result.isSuccess) {
log(TAG, WARN) { "querySkus() failed: code=${result.responseCode}, message=${result.debugMessage}" }
val firstSku = requested.firstOrNull()
if (result.responseCode == BillingResponseCode.ITEM_UNAVAILABLE && firstSku != null) {
// Merchandising state (region, account eligibility, pulled product), not a defect:
// typed so the user gets copy instead of a bug report.
throw OfferUnavailableBillingException(firstSku, null)
}
throw BillingClientException(result)
}
if (unfetched.isNotEmpty()) {
log(TAG, WARN) { "querySkus(): Play did not fetch ${unfetched.printed()}" }
}
// A malformed product ID is OUR configuration defect and stays on the reportable path. The
// merchandising statuses (product pulled, no eligible offer) are normal Play conditions and
// fall out of the per-sku matching below as OfferUnavailableBillingException.
unfetched
.firstOrNull { it.statusCode == UnfetchedProduct.StatusCode.INVALID_PRODUCT_ID_FORMAT }
?.let { invalid ->
throw BillingClientException(
BillingResult.newBuilder().apply {
setResponseCode(BillingResponseCode.DEVELOPER_ERROR)
setDebugMessage("Invalid product id format: ${invalid.productId} (${invalid.productType})")
}.build()
)
}
val returned = details.groupBy { it.productId to it.productType }
val requestedKeys = requested.map { it.id to it.playProductType }.toSet()
returned.keys
.filterNot { it in requestedKeys }
.forEach { (id, type) -> log(TAG, WARN) { "querySkus(): skipping unrequested product $id ($type)" } }
// Iterating the REQUEST (not the response) is what makes an omitted sku visible at all:
// walking the returned groups only ever visits what Play chose to send.
return requested.map { sku ->
val matches = returned[sku.id to sku.playProductType].orEmpty()
// Exactly one, never "the first of several": a duplicate row means we can't tell which
// one Play meant, and guessing would sell the user a different product.
val detail = matches.singleOrNull()
if (detail == null) {
log(TAG, WARN) {
"querySkus(): expected exactly 1 detail for ${sku.print()}, got ${matches.size} of $details"
}
throw OfferUnavailableBillingException(sku, null)
}
SkuDetails(sku, detail)
}
}
suspend fun launchBillingFlow(activity: Activity, sku: Sku, targetOffer: Sku.Subscription.Offer?): BillingResult {
log(TAG) { "launchBillingFlow(activity=$activity, sku=$sku)" }
if (sku.type == Sku.Type.SUBSCRIPTION) {
requireNotNull(targetOffer) { "SUB skus require a target offer" }
}
val skuDetails = querySkus(sku)
val data = skuDetails.singleOrNull { it.sku == sku }
if (data == null) {
log(TAG, WARN) { "launchBillingFlow(): no unique details for ${sku.print()} in $skuDetails" }
throw OfferUnavailableBillingException(sku, targetOffer)
}
val params = BillingFlowParams.newBuilder().apply {
val productDetail = BillingFlowParams.ProductDetailsParams.newBuilder().apply {
setProductDetails(data.details)
if (sku is Sku.Subscription && targetOffer != null) {
// singleOrNull, not single: an absent offer (withheld/revoked) and an ambiguous
// duplicate both mean we must not guess an offer token — the wrong one bills the
// user at the wrong price.
val offer = data.details.subscriptionOfferDetails?.singleOrNull {
targetOffer.matches(it)
}
if (offer == null) {
log(TAG, WARN) {
val available = data.details.subscriptionOfferDetails
?.map { "${it.basePlanId}/${it.offerId}" }
"launchBillingFlow(): offer ${targetOffer.basePlanId}/${targetOffer.offerId} " +
"unavailable for ${sku.print()}, available=$available"
}
throw OfferUnavailableBillingException(sku, targetOffer)
}
setOfferToken(offer.offerToken)
}
}.build()
setProductDetailsParamsList(listOf(productDetail))
}.build()
// launchBillingFlow must run on the main thread (documented BillingClient contract), and its
// RETURNED result reports whether the flow could be launched at all (DEVELOPER_ERROR,
// ITEM_ALREADY_OWNED, BILLING_UNAVAILABLE, ...) — failures arrive here, not as exceptions.
// Throw like the other client calls do, so callers can surface them instead of silence.
val result = withContext(Dispatchers.Main) {
client.launchBillingFlow(activity, params)
}
log(TAG) {
"launchBillingFlow(sku=$sku): code=${result.responseCode}, message=${result.debugMessage}"
}
if (!result.isSuccess) {
// Same merchandising state as at query time, just reported by the launch instead: the
// product/offer isn't purchasable, which is not a defect worth reporting.
if (result.responseCode == BillingResponseCode.ITEM_UNAVAILABLE) {
throw OfferUnavailableBillingException(sku, targetOffer)
}
throw BillingClientException(result)
}
return result
}
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientConnection")
// Play's product-type string for one of our SKUs. Part of the match key: a product ID alone
// is not unique across product types.
private val Sku.playProductType: String
get() = when (type) {
Sku.Type.IAP -> BillingClient.ProductType.INAPP
Sku.Type.SUBSCRIPTION -> BillingClient.ProductType.SUBS
}
// Log-friendly rendering of the products Play refused to fetch (no purchase data involved).
private fun Collection<UnfetchedProduct>.printed(): String =
joinToString(prefix = "[", postfix = "]") { "${it.productId}(${it.productType})=${it.statusCode}" }
// Classifies event purchases by product type at ingestion, so a later per-type query can
// authoritatively supersede them. Unknown products stay untyped (cleared only by a
// complete refresh).
internal val DEFAULT_SKU_TYPE_RESOLVER: (String) -> Sku.Type? = { productId ->
OurSku.PRO_SKUS.singleOrNull { it.id == productId }?.type
}
// Combines the two product-type query results: a purchase found by either type is
// authoritative; an error is only propagated when nothing was found, so callers can tell
// "not owned" apart from "couldn't verify one product type". Treating any found purchase
// as authoritative is safe because every product this app sells is a Pro SKU (see
// OurSku.PRO_SKUS). Pure and unit-tested.
internal fun combinePurchaseResults(
iap: Result<Collection<Purchase>>,
sub: Result<Collection<Purchase>>,
): Collection<Purchase> {
val found = iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()
return when {
found.isNotEmpty() -> found.sortedByDescending { it.purchaseTime }
else -> {
(iap.exceptionOrNull() ?: sub.exceptionOrNull())?.let { throw it }
emptyList()
}
}
}
}
}
@@ -0,0 +1,106 @@
package eu.darken.capod.common.upgrade.core.billing.client
import android.content.Context
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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 eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.billing.BillingException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
// A single connection attempt: emits one BillingConnection, stays open for its lifetime, and
// closes with the exception on setup failure or disconnect. NO retry here — BillingManager's
// connect loop owns all retry policy, so every wait stays interruptible by user demand (the old
// nested retryWhen added up to ~30s of demand-blind delays before the manager ever saw a failure).
@Singleton
class BillingConnectionProvider @Inject constructor(
@ApplicationContext private val context: Context,
) {
val connection: Flow<BillingConnection> = callbackFlow {
// The listener must exist before the client, the connection needs the client: bridge via
// this reference. onPurchasesUpdated can only fire for an ACTIVE connection, i.e. after
// setup finished and the reference was set — a null here would be a Play contract breach,
// and dropping such an event is the only sane response.
var connectionRef: BillingConnection? = null
val client = BillingClient.newBuilder(context).apply {
enablePendingPurchases(
PendingPurchasesParams.newBuilder().apply {
enableOneTimeProducts()
}.build()
)
setListener { result, purchases ->
connectionRef?.onPurchasesUpdated(result, purchases)
?: log(TAG, WARN) { "onPurchasesUpdated(code=${result.responseCode}) before setup finished?!" }
}
}.build()
// A never-answering Play (no setup callback at all) must fail this attempt into the
// manager's backoff instead of hanging it outside any timeout.
val setupTimeout = launch {
delay(SETUP_TIMEOUT_MS)
close(BillingException("Billing client setup timed out"))
}
log(TAG, VERBOSE) { "startConnection(...)" }
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
setupTimeout.cancel()
log(TAG, VERBOSE) {
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
}
when (result.responseCode) {
BillingResponseCode.OK -> {
val connection = BillingConnection(client)
connectionRef = connection
trySendBlocking(connection)
}
else -> {
close(BillingClientException(result))
}
}
}
override fun onBillingServiceDisconnected() {
log(TAG) { "onBillingServiceDisconnected() " }
close(BillingException("Billing service disconnected"))
}
})
log(TAG) { "Awaiting close." }
awaitClose {
try {
log(TAG) { "Stopping billing client connection" }
// Complete the event channels first so consumers stop waiting on a dead connection.
connectionRef?.close()
client.endConnection()
} catch (e: Exception) {
log(TAG, WARN) { "Couldn't end billing client connection: ${e.asLog()}" }
}
}
}.setupCommonEventHandlers(TAG) { "provider" }
companion object {
private const val SETUP_TIMEOUT_MS = 30_000L
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
}
}
@@ -1,379 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
import android.app.Activity
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
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.core.CapodSku
import eu.darken.capod.common.upgrade.core.data.Sku
import eu.darken.capod.common.upgrade.core.data.SkuDetails
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class BillingClientConnection(
private val client: BillingClient,
private val purchasesGlobal: MutableStateFlow<Collection<Purchase>>,
private val freshObservations: MutableSharedFlow<FreshPurchases>,
private val freshFailuresGlobal: MutableSharedFlow<Unit>,
private val purchaseFailuresGlobal: Flow<BillingResult>,
private val listenerGeneration: () -> Long,
) {
// Non-OK results from onPurchasesUpdated (e.g. async ITEM_ALREADY_OWNED after the Play sheet
// opened). Consumed by a single persistent collector in UpgradeRepoGplay — not an event bus.
val purchaseFailures: Flow<BillingResult> = purchaseFailuresGlobal
// Every conclusive fresh look at PURCHASED purchases (successful queries and push payloads),
// regardless of whether it differs from the previous one — the combined `purchases` state is
// equality-deduped and can mix in stale listener data, so grace stamping must not use it.
// Each observation carries provenance: only a full snapshot proves absence.
val freshPurchases: Flow<FreshPurchases> = freshObservations
// Failed attempts to get a fresh conclusive look (query errors, initial-query timeout).
// Consumed by UpgradeRepoGplay to start the unconfirmed-episode clock — without this, a
// sustained Play outage would never escalate the grace UI to its diagnostics stage.
val freshFailures: Flow<Unit> = freshFailuresGlobal
private data class QueryCaches(
val iaps: Collection<Purchase>? = null,
val subs: Collection<Purchase>? = null,
)
private val queryCache = MutableStateFlow(QueryCaches())
// Serializes refreshes on this connection: the connect-time initial query, foreground
// refreshes, manual restores, switch-gate verifications and already-owned recoveries may
// overlap, and an older query completing late must not overwrite the cache with stale
// purchases.
private val refreshLock = Mutex()
val purchases: Flow<Collection<Purchase>> = combine(
purchasesGlobal,
queryCache,
) { global, cached ->
// Dedup by purchaseToken, not Purchase identity: the query-cache snapshot and the listener
// overlay can hold the SAME purchase with a different ack-state — Play's immutable Purchase
// keeps reporting isAcknowledged=false until a fresh query supersedes it — so a Set/equals
// dedup (originalJson+signature differ) would keep BOTH. Insert the query-cache entries
// first, then let the listener overlay overwrite by token: reconcileListenerRecords() has
// already dropped same-token listener records after a non-raced query and deliberately KEEPS
// them when a purchase raced the query, so a surviving overlay entry is by construction the
// newer one — the overlay is not unconditionally fresher, it only wins when reconciliation
// left it in place.
val byToken = LinkedHashMap<String, Purchase>()
fun keep(purchase: Purchase) {
if (purchase.purchaseToken.isBlank()) {
// Play supplies a non-empty token for PURCHASED purchases; a blank one is malformed
// and must not collapse every such record under the "" key.
log(TAG, WARN) { "Ignoring PURCHASED record with blank purchaseToken: $purchase" }
return
}
byToken[purchase.purchaseToken] = purchase
}
cached.iaps?.forEach { keep(it) }
cached.subs?.forEach { keep(it) }
global
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
.forEach { keep(it) }
byToken.values.sortedByDescending { it.purchaseTime }
}
.setupCommonEventHandlers(TAG) { "purchases" }
// Returns the freshly queried PURCHASED purchases so callers get a guaranteed happens-before
// relation instead of racing the shared purchases/billingData replay caches after a refresh.
// Tolerant of a single product-type failure: a known Pro purchase found by either type is
// authoritative, and an error only surfaces otherwise — so callers can tell "not owned" apart
// from "couldn't verify".
suspend fun refreshPurchases(): FreshPurchases = refreshLock.withLock {
refreshPurchasesLocked()
}
private suspend fun refreshPurchasesLocked(): FreshPurchases = coroutineScope {
val generationBefore = listenerGeneration()
val iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
val iaps = iapsDeferred.await()
val subs = subsDeferred.await()
log(TAG) { "refreshPurchases(): iaps=${iaps.getOrNull()}, subs=${subs.getOrNull()}" }
// Evaluate before publishing: an inconclusive refresh (query failed and nothing
// authoritative found) must not touch the caches at all, or a partially updated state
// could surface synthetic ownership to the hot purchases flow and wrongly refresh the
// grace anchor from stale data.
val combined = try {
combinePurchaseResults(iaps, subs)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
freshFailuresGlobal.tryEmit(Unit)
throw e
}
// Single atomic snapshot update; a failed type retains its previous value.
queryCache.update { previous ->
QueryCaches(
iaps = iaps.getOrNull() ?: previous.iaps,
subs = subs.getOrNull() ?: previous.subs,
)
}
val bothOk = iaps.isSuccess && subs.isSuccess
reconcileListenerRecords(
fresh = combined,
generationBefore = generationBefore,
// A conclusive both-type query proves absence for every product: listener records it
// didn't return are stale (refunded, expired) and must not linger until restart.
absenceProven = bothOk,
absenceScope = { true },
)
// Absence is only proven when both type queries succeeded AND no purchase event raced the
// queries: a racing event either flips the generation (downgrading this to presence-only),
// or its own fresh emission follows ours and immediately re-stamps the confirmation.
val isFullSnapshot = bothOk && listenerGeneration() == generationBefore
val fresh = FreshPurchases(combined, isFullSnapshot)
// A conclusive refresh is a fresh observation for the grace stamping, even when the result
// equals the previous one and the state flows dedupe it away.
freshObservations.tryEmit(fresh)
fresh
}
// Strict SUBS-only verification query for the switch-to-IAP gate: errors propagate (the
// caller fails closed), and the result is committed to the caches so the ownership UI heals
// from stale renewal state (e.g. after the user cancelled the subscription in Play).
suspend fun querySubscriptions(): Collection<Purchase> = refreshLock.withLock {
val generationBefore = listenerGeneration()
val subs = try {
queryPurchasesByType(BillingClient.ProductType.SUBS)
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
freshFailuresGlobal.tryEmit(Unit)
throw e
}
queryCache.update { it.copy(subs = subs) }
reconcileListenerRecords(
fresh = subs,
generationBefore = generationBefore,
// A conclusive SUBS query proves absence for subscriptions only.
absenceProven = true,
absenceScope = { it.isKnownSub() },
)
// Presence-only provenance: a SUBS query proves nothing about the IAP.
freshObservations.tryEmit(FreshPurchases(subs, isFullSnapshot = false))
// Include listener-known subscription records the query doesn't cover (a purchase racing
// the query survives reconciliation) — over-blocking the switch is safe, missing a
// renewing sub is not.
val listenerSubs = purchasesGlobal.value.filter { listenerRecord ->
listenerRecord.purchaseState == Purchase.PurchaseState.PURCHASED &&
listenerRecord.isKnownSub() &&
subs.none { it.purchaseToken == listenerRecord.purchaseToken }
}
subs + listenerSubs
}
private fun Purchase.isKnownSub(): Boolean = products.any { it == CapodSku.Sub.PRO_UPGRADE.id }
// Reconciles the listener overlay against a fresh query result — but ONLY when no purchase
// event raced the query (generation unchanged, re-checked inside the CAS loop): a listener
// record published mid-query is NEWER than the query result and must survive, or a
// just-renewed subscription could be deleted by an older query and slip past the IAP gate.
// Without a race: fresh records supersede same-token listener records (a stale in-session
// isAutoRenewing=true must not coexist with newer query state forever), and when absence was
// proven, in-scope listener records the query didn't return are dropped entirely (refunded or
// expired purchases must not keep resurrecting until process restart).
private fun reconcileListenerRecords(
fresh: Collection<Purchase>,
generationBefore: Long,
absenceProven: Boolean,
absenceScope: (Purchase) -> Boolean,
) {
purchasesGlobal.update { current ->
if (listenerGeneration() != generationBefore) return@update current
current.filterNot { old ->
fresh.any { it.purchaseToken == old.purchaseToken } || (absenceProven && absenceScope(old))
}
}
}
// Never throws except on cancellation, so a single failing product-type query doesn't cancel
// the sibling query (or the coroutineScope).
private suspend fun queryPurchasedProducts(
productType: String,
): Result<Collection<Purchase>> = try {
val purchased = queryPurchasesByType(productType)
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
Result.success(purchased)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
private suspend fun queryPurchasesByType(productType: String): Collection<Purchase> {
val params = QueryPurchasesParams.newBuilder()
.setProductType(productType)
.build()
val queryResult = client.queryPurchasesAsync(params)
log(TAG) { "queryPurchases($productType): code=${queryResult.billingResult.responseCode}, message=${queryResult.billingResult.debugMessage}, purchases=${queryResult.purchasesList}" }
if (!queryResult.billingResult.isSuccess) {
log(TAG, WARN) { "queryPurchases($productType) failed" }
throw BillingResultException(queryResult.billingResult)
}
return queryResult.purchasesList
}
suspend fun acknowledgePurchase(purchase: Purchase) {
val ack = AcknowledgePurchaseParams.newBuilder().apply {
setPurchaseToken(purchase.purchaseToken)
}.build()
val result = client.acknowledgePurchase(ack)
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
if (!result.isSuccess) throw BillingResultException(result)
}
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> {
val byType = skus.groupBy { it.type }
val results = mutableListOf<SkuDetails>()
for ((type, typeSkus) in byType) {
val productType = when (type) {
Sku.Type.IAP -> BillingClient.ProductType.INAPP
Sku.Type.SUBSCRIPTION -> BillingClient.ProductType.SUBS
}
val products = typeSkus.map { sku ->
QueryProductDetailsParams.Product.newBuilder().apply {
setProductType(productType)
setProductId(sku.id)
}.build()
}
val params = QueryProductDetailsParams.newBuilder().setProductList(products).build()
val queryResult = client.queryProductDetails(params)
val details = queryResult.productDetailsList.orEmpty()
log(TAG) {
"querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${queryResult.billingResult.responseCode}, debug=${queryResult.billingResult.debugMessage}, details=$details"
}
if (!queryResult.billingResult.isSuccess) throw BillingResultException(queryResult.billingResult)
for (detail in details) {
val matchingSku = typeSkus.firstOrNull { it.id == detail.productId }
if (matchingSku != null) {
results.add(SkuDetails(matchingSku, detail))
}
}
}
return results
}
suspend fun launchBillingFlow(
activity: Activity,
sku: Sku,
offer: Sku.Subscription.Offer? = null,
): BillingResult {
log(TAG) { "launchBillingFlow(activity=$activity, sku=$sku, offer=$offer)" }
val skuDetails = querySkus(sku).firstOrNull()
?: throw IllegalStateException("Unknown SKU, no details available for ${sku.id}")
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply {
setProductDetails(skuDetails.details)
if (sku is Sku.Subscription && offer != null) {
val offerDetails = skuDetails.details.subscriptionOfferDetails
?.firstOrNull { offer.matches(it) }
if (offerDetails != null) {
setOfferToken(offerDetails.offerToken)
}
}
}.build()
val billingFlowParams = BillingFlowParams.newBuilder().apply {
setProductDetailsParamsList(listOf(productParams))
}.build()
// launchBillingFlow must run on the main thread (documented BillingClient contract), and
// its RETURNED result reports whether the flow could be launched at all (ITEM_ALREADY_OWNED,
// BILLING_UNAVAILABLE, DEVELOPER_ERROR, ...) — launch failures arrive here, not as
// exceptions. Throw like the sibling methods do, so callers can surface them instead of
// failing silently.
val result = withContext(Dispatchers.Main) {
client.launchBillingFlow(activity, billingFlowParams)
}
log(TAG) { "launchBillingFlow(sku=${sku.id}): code=${result.responseCode}, message=${result.debugMessage}" }
if (!result.isSuccess) throw BillingResultException(result)
return result
}
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientConnection")
// Combines the two product-type query results: with a partial failure, only a known Pro
// purchase found by the successful type may suppress the error — an unknown/legacy purchase
// must not mask that the other type couldn't be verified. Without failures, everything
// found is returned as-is. Pure and unit-tested.
internal fun combinePurchaseResults(
iaps: Result<Collection<Purchase>>,
subs: Result<Collection<Purchase>>,
isAuthoritative: (Purchase) -> Boolean = { purchase ->
purchase.products.any { productId -> CapodSku.PRO_SKUS.any { it.id == productId } }
},
): Collection<Purchase> {
val found = iaps.getOrNull().orEmpty() + subs.getOrNull().orEmpty()
val error = iaps.exceptionOrNull() ?: subs.exceptionOrNull()
return when {
error == null || found.any(isAuthoritative) -> found.sortedByDescending { it.purchaseTime }
else -> throw error
}
}
}
}
@@ -1,200 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
import android.content.Context
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.newBuilder
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.Purchase
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.*
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 eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BillingClientConnectionProvider @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val connectionProvider: Flow<BillingClientConnection> = callbackFlow {
val purchasePublisher = MutableStateFlow<Collection<Purchase>>(emptySet())
// Events, not state: fresh observations feed the grace stamping (every successful query or
// push payload counts, even if equal to the previous one — Purchase.equals would dedupe a
// StateFlow), and failures must not be conflated away (Play reuses BillingResult instances,
// so a repeated ITEM_ALREADY_OWNED could be a same-instance emission).
// replay=1 on observations: the connect-time query can complete before the grace recorder
// subscribes (construction order race) — the latest fresh observation must not be lost.
// Failures stay replay=0: they can only originate from a purchase flow, which requires the
// consumer to already exist, and a consumed event must not be re-delivered.
val freshPurchaseObservations = MutableSharedFlow<FreshPurchases>(
replay = 1,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val purchaseFailureEvents = MutableSharedFlow<BillingResult>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
// replay=1: the connect-time initial query can fail or time out before UpgradeRepoGplay
// subscribes — that first failure must still start the unconfirmed-episode clock. A stale
// replayed failure is harmless (episode recording is set-if-unset and guarded).
val freshFailureEvents = MutableSharedFlow<Unit>(
replay = 1,
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
// Bumped on every successful onPurchasesUpdated BEFORE its data is published: a refresh
// compares the generation around its queries to detect a racing purchase event, which
// downgrades that refresh's snapshot from "proves absence" to "presence only".
val listenerGeneration = AtomicLong(0)
val client = newBuilder(context).apply {
enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.enablePrepaidPlans()
.build()
)
setListener { result, purchases ->
if (result.isSuccess) {
log(TAG) {
"onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases)"
}
listenerGeneration.incrementAndGet()
purchasePublisher.value = purchases.orEmpty()
freshPurchaseObservations.tryEmit(
FreshPurchases(
purchases = purchases.orEmpty().filter { it.purchaseState == Purchase.PurchaseState.PURCHASED },
// Push payloads only carry this session's purchases — they prove
// presence, never absence.
isFullSnapshot = false,
)
)
} else {
log(TAG, WARN) {
"error: onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases)"
}
// Failures are published too: async ITEM_ALREADY_OWNED (Play telling us mid-flow
// that the user already owns it) drives the auto-restore in UpgradeRepoGplay.
purchaseFailureEvents.tryEmit(result)
}
}
}.build()
log(TAG, VERBOSE) { "startConnection(...)" }
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
log(TAG, VERBOSE) {
"onBillingSetupFinished(code=${result.responseCode}, message=${result.debugMessage})"
}
when (result.responseCode) {
BillingResponseCode.OK -> {
val connection = BillingClientConnection(
client = client,
purchasesGlobal = purchasePublisher,
freshObservations = freshPurchaseObservations,
freshFailuresGlobal = freshFailureEvents,
purchaseFailuresGlobal = purchaseFailureEvents,
listenerGeneration = { listenerGeneration.get() },
)
trySendBlocking(connection)
launch {
try {
// Bounded: a hung Play callback would otherwise hold the refresh
// lock indefinitely and starve every later refresh on this
// connection (foreground, manual restore, already-owned recovery).
val initial = withTimeoutOrNull(INITIAL_QUERY_TIMEOUT_MS) {
connection.refreshPurchases()
}
if (initial != null) {
log(TAG) { "Initial purchase query successful." }
} else {
log(TAG, WARN) { "Initial purchase query timed out." }
// The timeout cancels the query before its own failure path
// runs — signal it here so the unconfirmed-episode clock can
// start during a sustained Play outage.
freshFailureEvents.tryEmit(Unit)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "Initial purchase query failed:\n${e.asLog()}" }
}
}
}
else -> {
close(BillingResultException(result))
}
}
}
override fun onBillingServiceDisconnected() {
log(TAG, VERBOSE) { "onBillingServiceDisconnected() " }
close(BillingException("Billing service disconnected"))
}
})
log(TAG) { "Awaiting close." }
awaitClose {
log(TAG) { "Stopping billing client connection" }
client.endConnection()
}
}
val connection: Flow<BillingClientConnection> = connectionProvider
.setupCommonEventHandlers(TAG) { "connection" }
.retryWhen { cause, attempt ->
log(TAG) { "Billing client connection error: ${cause.asLog()}" }
if (cause is CancellationException) {
log(TAG) { "BillingClient connection cancelled." }
return@retryWhen false
}
if (cause !is BillingException) {
log(TAG, WARN) { "Unknown exception type: $cause" }
return@retryWhen false
}
if (cause is BillingResultException && cause.result.isGplayUnavailablePermanent) {
log(TAG) { "Got BILLING_UNAVAILABLE while trying to connect client." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
return@retryWhen false
}
log(TAG) { "Will retry BillingClient connection... *sigh*" }
delay(3000 * attempt)
true
}
companion object {
private const val INITIAL_QUERY_TIMEOUT_MS = 30_000L
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
}
}
@@ -1,18 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
internal val BillingResult.isSuccess: Boolean
get() = responseCode == BillingClient.BillingResponseCode.OK
internal val BillingResult.isGplayUnavailableTemporary: Boolean
get() = setOf(
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
BillingClient.BillingResponseCode.SERVICE_TIMEOUT,
BillingClient.BillingResponseCode.NETWORK_ERROR,
).contains(responseCode)
internal val BillingResult.isGplayUnavailablePermanent: Boolean
get() = responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE
@@ -1,18 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
import android.content.Context
import com.android.billingclient.api.BillingResult
import eu.darken.capod.R
import eu.darken.capod.common.error.LocalizedError
class BillingResultException(val result: BillingResult) : BillingException(result.debugMessage) {
override fun toString(): String =
"BillingResultException(code=${result.responseCode}, message=${result.debugMessage})"
override fun getLocalizedError(context: Context): LocalizedError = LocalizedError(
throwable = this,
label = context.getString(R.string.upgrades_gplay_billing_result_error_label),
description = context.getString(R.string.upgrades_gplay_billing_result_error_description, result)
)
}
@@ -1,11 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.Purchase
// Provenance-tagged fresh purchase observation: a full snapshot (both product types queried
// conclusively, with no purchase event racing the queries) proves absence; anything else — push
// payloads, single-type queries, partial or raced refreshes — proves presence only.
data class FreshPurchases(
val purchases: Collection<Purchase>,
val isFullSnapshot: Boolean,
)
@@ -1,5 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
// The user backed out of the Google Play payment sheet — expected control-flow outcome,
// handled silently by the UI layer, never shown as an error.
class UserCanceledBillingException(cause: Throwable) : Exception("User canceled the billing flow.", cause)
@@ -1,24 +0,0 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.CapodSku
data class BillingData(
val purchases: Collection<Purchase>
) {
val purchasedSkus: Collection<PurchasedSku>
get() = purchases.flatMap { purchase ->
purchase.products.mapNotNull { productId ->
val sku = CapodSku.PRO_SKUS.singleOrNull { it.id == productId }
sku?.let { PurchasedSku(it, purchase) }
}
}
}
// Provenance-tagged fresh billing data: only a full snapshot (both product types queried
// conclusively, no racing purchase event) proves absence — anything else proves presence only.
// The grace machinery relies on this to never start an unconfirmed episode from partial data.
data class FreshBillingData(
val data: BillingData,
val isFullSnapshot: Boolean,
)
@@ -1,292 +0,0 @@
package eu.darken.capod.common.upgrade.core.data
import android.app.Activity
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.AppForegroundState
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
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 eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.client.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BillingDataRepo @Inject constructor(
billingClientConnectionProvider: BillingClientConnectionProvider,
@AppScope private val scope: CoroutineScope,
private val appForegroundState: AppForegroundState,
private val timeSource: TimeSource,
) {
// Monotonic (elapsedRealtime) so wall-clock corrections can't extend the throttle window.
// Null until the first attempt, so devices with less than an hour of uptime still refresh.
private var lastForegroundRefreshAt: Long? = null
// Explicit billing operations kick a waiting connection-retry backoff so a user action (restore
// tap, buy tap, opening the upgrade screen) reconnects immediately after Play was fixed instead
// of waiting out the timer. Zero replay: kicks only matter while a retry is actively waiting —
// a healthy connection must not accumulate stale wake-ups. (Ported from sdmaid-se#2562.)
private val connectionKicks = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val connectionProvider = billingClientConnectionProvider.connection
.retryWhen { cause, attempt ->
if (cause is CancellationException) return@retryWhen false
log(TAG, ERROR) { "Unable to provide client connection (attempt=$attempt):\n${cause.asLog()}" }
// Capped backoff: don't hammer a persistently broken Play from the always-hot process
// (upstream already did 5 quick retries). An explicit billing operation or a foreground
// *entry* short-circuits the wait — e.g. the user just returned from signing into the
// missing Google account and shouldn't have to wait out the full backoff.
val backoffMs = (RETRY_BACKOFF_BASE_MS * (attempt + 1)).coerceAtMost(RETRY_BACKOFF_MAX_MS)
val kicked = withTimeoutOrNull(backoffMs) {
merge(
connectionKicks,
// StateFlow dedupes, so after dropping the current value the next `true` is a
// real foreground *entry*, not the pre-existing foreground state.
appForegroundState.isForeground.drop(1).filter { it }.map { },
).first()
}
if (kicked != null) log(TAG) { "User action or foreground entry, retrying billing connection early" }
true
}
.replayingShare(scope)
val billingData: Flow<BillingData> = connectionProvider
.flatMapLatest { it.purchases }
.map { BillingData(purchases = it) }
.setupCommonEventHandlers(TAG) { "billingData" }
.replayingShare(scope)
// Async purchase failures from onPurchasesUpdated; UpgradeRepoGplay reconciles
// ITEM_ALREADY_OWNED silently.
val purchaseFailures: Flow<BillingResult> = connectionProvider
.flatMapLatest { it.purchaseFailures }
.setupCommonEventHandlers(TAG) { "purchaseFailures" }
// Every fresh observation of PURCHASED purchases (successful queries and push payloads) —
// unlike billingData this is not equality-deduped state and never mixes in stale listener
// data, so it is the only valid source for grace stamping. Carries provenance: only full
// snapshots may prove absence.
val freshBillingData: Flow<FreshBillingData> = connectionProvider
.flatMapLatest { it.freshPurchases }
.map { FreshBillingData(data = BillingData(purchases = it.purchases), isFullSnapshot = it.isFullSnapshot) }
.setupCommonEventHandlers(TAG) { "freshBillingData" }
// Local failures the connection can't see itself (e.g. a foreground refresh timing out while
// the connection retry backoff is still waiting).
private val localRefreshFailures = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
// Failed attempts to get fresh conclusive purchase data (query errors, timeouts). Consumed by
// UpgradeRepoGplay to start the unconfirmed-episode clock during sustained Play outages.
val refreshFailures: Flow<Unit> = merge(
connectionProvider.flatMapLatest { it.freshFailures },
localRefreshFailures,
)
// Tokens successfully acknowledged this process: the immutable Purchase snapshots in the
// combined view keep claiming isAcknowledged=false until a fresh query supersedes them, and
// every re-emission would otherwise re-ack the same purchase — harmless to Play (repeat acks
// return OK) but a pointless extra IPC each time. Only recorded on SUCCESS, so a failed ack
// stays retryable. Single sequential collector, no locking needed.
private val ackedTokens = mutableSetOf<String>()
init {
connectionProvider
.flatMapLatest { client ->
client.purchases.map { client to it }
}
.onEach { (client, purchases) ->
purchases
.filter {
// Only settled purchases can be acknowledged — acking a PENDING purchase
// fails and would spin the retry loop below.
val needsAck = !it.isAcknowledged &&
it.purchaseState == Purchase.PurchaseState.PURCHASED &&
it.purchaseToken !in ackedTokens
if (needsAck) log(TAG, INFO) { "Needs ACK: $it" }
else log(TAG) { "No ACK necessary: $it" }
needsAck
}
.forEach {
log(TAG, INFO) { "Acknowledging purchase: $it" }
client.acknowledgePurchase(it)
ackedTokens.add(it.purchaseToken)
}
}
.setupCommonEventHandlers(TAG) { "connection-acks" }
.retryWhen { cause, attempt ->
log(TAG, ERROR) { "Failed to acknowledge purchase: ${cause.asLog()}" }
if (cause is CancellationException) {
log(TAG) { "Ack was cancelled (appScope?) cancelled." }
return@retryWhen false
}
if (attempt > 5) {
log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" }
return@retryWhen false
}
if (cause !is BillingException) {
log(TAG, WARN) { "Unknown exception type: $cause" }
return@retryWhen false
}
if (cause is BillingResultException && cause.result.isGplayUnavailablePermanent) {
log(TAG) { "Got BILLING_UNAVAILABLE while trying to ACK purchase." }
return@retryWhen false
}
log(TAG) { "Will retry ACK (attempt=$attempt)" }
delay(3000 * attempt)
true
}
.launchIn(scope)
// Play only pushes onPurchasesUpdated for purchases made in this session, and the
// connection (kept hot by App's AppScope subscriber) can live for the entire process
// lifetime — without a re-query, refunds, cross-device purchases or lapsed subscriptions
// are only noticed on app restart or manual restore. Google recommends re-querying
// purchases when the app comes to the foreground.
appForegroundState.isForeground
.filter { it }
.onEach {
val now = timeSource.elapsedRealtime()
val lastAt = lastForegroundRefreshAt
if (lastAt != null && now - lastAt < FOREGROUND_REFRESH_THROTTLE_MS) {
log(TAG, VERBOSE) { "Foreground purchase refresh throttled" }
return@onEach
}
// Advanced per attempt, not per success — a broken Play should not be hammered
// on every foreground transition.
lastForegroundRefreshAt = now
try {
// Bounded: an unavailable connection suspends refresh() indefinitely (60s
// retry loop) and would otherwise block all future foreground refreshes.
val result = withTimeoutOrNull(FOREGROUND_REFRESH_TIMEOUT_MS) { refresh() }
if (result != null) {
log(TAG) { "Foreground purchase refresh done" }
} else {
log(TAG, WARN) { "Foreground purchase refresh timed out" }
// The timeout cancels the query before its own failure path can signal —
// report it here so the unconfirmed-episode clock still starts.
localRefreshFailures.tryEmit(Unit)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Foreground purchase refresh failed: ${e.asLog()}" }
}
}
.setupCommonEventHandlers(TAG) { "foreground-refresh" }
.launchIn(scope)
}
suspend fun refresh(): FreshBillingData = try {
connectionKicks.tryEmit(Unit)
val clientConnection = connectionProvider.first()
val fresh = clientConnection.refreshPurchases()
FreshBillingData(data = BillingData(purchases = fresh.purchases), isFullSnapshot = fresh.isFullSnapshot)
} catch (e: Exception) {
throw e.tryMapUserFriendly()
}
// Strict SUBS-only ownership check for the switch-to-IAP gate: errors propagate so callers
// can fail closed, and the fresh result is committed so stale renewal state heals.
suspend fun querySubscriptions(): Collection<Purchase> = try {
connectionKicks.tryEmit(Unit)
val clientConnection = connectionProvider.first()
clientConnection.querySubscriptions()
} catch (e: Exception) {
throw e.tryMapUserFriendly()
}
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> = try {
connectionKicks.tryEmit(Unit)
val clientConnection = connectionProvider.first()
clientConnection.querySkus(*skus)
} catch (e: Exception) {
throw e.tryMapUserFriendly()
}
suspend fun startBillingFlow(
activity: Activity,
sku: Sku,
offer: Sku.Subscription.Offer? = null,
) {
try {
connectionKicks.tryEmit(Unit)
val clientConnection = connectionProvider.first()
clientConnection.launchBillingFlow(activity, sku, offer)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to start billing flow:\n${e.asLog()}" }
if (e !is BillingResultException || e.result.responseCode !in IGNORED_LAUNCH_CODES) {
Bugs.report(TAG, "Billing flow failed for $sku", e)
}
throw e.tryMapUserFriendly()
}
}
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo")
private const val FOREGROUND_REFRESH_THROTTLE_MS = 60 * 60 * 1000L // 1h
private const val FOREGROUND_REFRESH_TIMEOUT_MS = 30_000L
private const val RETRY_BACKOFF_BASE_MS = 60_000L
private const val RETRY_BACKOFF_MAX_MS = 5 * 60_000L
// Expected environmental/user situations — user-facing handling only, no bug report.
// USER_CANCELED stays silent in the UI, ITEM_ALREADY_OWNED is auto-handled by
// UpgradeRepoGplay (restore instead of error), the service/network codes are transient
// connectivity states the user sees a proper error dialog for. Actionable codes
// (DEVELOPER_ERROR, ITEM_UNAVAILABLE, unknown future codes) keep reporting.
@Suppress("DEPRECATION")
internal val IGNORED_LAUNCH_CODES = setOf(
BillingClient.BillingResponseCode.USER_CANCELED,
BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
BillingClient.BillingResponseCode.ERROR,
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED,
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
BillingClient.BillingResponseCode.SERVICE_TIMEOUT,
BillingClient.BillingResponseCode.NETWORK_ERROR,
BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
)
internal fun Throwable.tryMapUserFriendly(): Throwable = when {
this is BillingResultException && this.result.isGplayUnavailableTemporary -> {
GplayServiceUnavailableException(this)
}
this is BillingResultException && this.result.isGplayUnavailablePermanent -> {
GplayServiceUnavailableException(this)
}
this is BillingResultException &&
this.result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> {
UserCanceledBillingException(this)
}
this is BillingResultException &&
this.result.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
ItemAlreadyOwnedBillingException(this)
}
else -> this
}
}
}
@@ -1,7 +0,0 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase
data class PurchasedSku(val sku: Sku, val purchase: Purchase) {
override fun toString(): String = "PurchasedSku(sku=$sku, purchase=${purchase.products})"
}
@@ -1,26 +0,0 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.ProductDetails
interface Sku {
val id: String
val type: Type
interface Iap : Sku {
override val type: Type get() = Type.IAP
}
interface Subscription : Sku {
override val type: Type get() = Type.SUBSCRIPTION
val offers: Collection<Offer>
interface Offer {
val basePlanId: String
val offerId: String?
fun matches(target: ProductDetails.SubscriptionOfferDetails): Boolean =
basePlanId == target.basePlanId && offerId == target.offerId
}
}
enum class Type { IAP, SUBSCRIPTION }
}
@@ -0,0 +1,17 @@
package eu.darken.capod.common.upgrade.ui
sealed class UpgradeEvents {
data object RestoreSucceeded : UpgradeEvents()
/** Play answered and no purchase was found. A real result: troubleshooting and escalation apply. */
data object RestoreFailed : UpgradeEvents()
/**
* The restore didn't finish within its budget, so ownership is simply unknown. Kept apart from
* [RestoreFailed] because that dialog asserts a completed check and steers toward the
* multi-account explanation, neither of which is warranted here.
*/
data object RestoreInconclusive : UpgradeEvents()
data object SubscriptionStillRenewing : UpgradeEvents()
data object SubscriptionCheckFailed : UpgradeEvents()
}
@@ -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,213 @@
package eu.darken.capod.common.upgrade.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
// The acquisition offers box: header, offer rows, "or" divider, parity footnote. Own file so the
// box can be iterated via the previews below.
@Composable
internal fun LoadedOffers(
uiState: GplayUpgradeUiState.Loaded,
onIap: () -> Unit,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.ACTIONS),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
UpgradeSectionHeader(
title = stringResource(R.string.upgrade_screen_offers_title),
icon = Icons.TwoTone.Stars,
)
val subscriptionText = stringResource(
when (uiState.subscriptionAction) {
SubscriptionAction.TRIAL -> R.string.upgrade_screen_subscription_trial_action
SubscriptionAction.STANDARD,
SubscriptionAction.UNAVAILABLE,
-> R.string.upgrade_screen_subscription_action
}
)
Spacer(modifier = Modifier.height(8.dp))
UpgradeOfferRow(
title = stringResource(R.string.upgrade_screen_subscription_offer_title),
price = uiState.subscriptionPrice,
// Only promise the trial when Play actually returned the trial offer.
hint = stringResource(
if (uiState.subscriptionAction == SubscriptionAction.TRIAL) {
R.string.upgrade_screen_subscription_offer_body
} else {
R.string.upgrade_screen_subscription_offer_body_no_trial
}
),
) {
Button(
onClick = when (uiState.subscriptionAction) {
SubscriptionAction.TRIAL -> onSubscriptionTrial
SubscriptionAction.STANDARD,
SubscriptionAction.UNAVAILABLE,
-> onSubscription
},
// Locked while ANY entitlement action runs: two concurrent billing launches (or a
// launch racing a restore) must not be startable from here.
enabled = uiState.subscriptionEnabled && uiState.busy == null,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION),
) {
// The spinner marks the action the user actually started, never a sibling one.
if (uiState.busy == BusyOp.SUBSCRIPTION) {
CircularProgressIndicator(
modifier = Modifier
.size(18.dp)
.testTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION_SPINNER),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(subscriptionText)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.upgrade_screen_offers_or),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(8.dp))
UpgradeOfferRow(
title = stringResource(R.string.upgrade_screen_iap_offer_title),
price = uiState.iapPrice,
hint = stringResource(R.string.upgrade_screen_iap_offer_body),
) {
OutlinedButton(
onClick = onIap,
enabled = uiState.iapEnabled && uiState.busy == null,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_IAP),
) {
if (uiState.busy == BusyOp.IAP) {
CircularProgressIndicator(
modifier = Modifier
.size(18.dp)
.testTag(UpgradeScreenTags.GPLAY_IAP_SPINNER),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.upgrade_screen_iap_action))
}
}
Spacer(modifier = Modifier.height(12.dp))
UpgradeHintText(text = stringResource(R.string.upgrade_screen_offers_body))
}
}
// Title and price share one line ("·"-joined in code: direction-neutral punctuation, not
// translatable copy), terms follow as body text, then the action — the terms must not repeat
// the button label.
@Composable
internal fun UpgradeOfferRow(
title: String,
price: String?,
modifier: Modifier = Modifier,
hint: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.fillMaxWidth(),
) {
Text(
text = listOfNotNull(title, price).joinToString(" · "),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
hint?.let { UpgradeSectionBody(text = it) }
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
private fun previewLoadedOffersState(
subscriptionAction: SubscriptionAction = SubscriptionAction.TRIAL,
) = GplayUpgradeUiState.Loaded(
subscriptionAction = subscriptionAction,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
)
@Preview2
@Composable
private fun LoadedOffersPreview() {
PreviewWrapper {
// Inside the real container so spacing and colors match the device.
UpgradeActionCard {
LoadedOffers(
uiState = previewLoadedOffersState(),
onIap = {},
onSubscription = {},
onSubscriptionTrial = {},
)
}
}
}
@Preview2
@Composable
private fun LoadedOffersNoTrialPreview() {
PreviewWrapper {
UpgradeActionCard {
LoadedOffers(
uiState = previewLoadedOffersState(subscriptionAction = SubscriptionAction.STANDARD),
onIap = {},
onSubscription = {},
onSubscriptionTrial = {},
)
}
}
}
@@ -0,0 +1,337 @@
package eu.darken.capod.common.upgrade.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Autorenew
import androidx.compose.material.icons.twotone.Verified
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
// Ownership presentation for users who already own a Pro entitlement. Subscribers without the
// one-time purchase always see the switch offer — but LOCKED while the subscription still
// renews, so buying it can't stack with an upcoming renewal.
@Composable
internal fun UpgradeOwnershipContent(
uiState: GplayUpgradeUiState.Loaded,
onIap: () -> Unit,
onManageSubscription: () -> Unit,
onRestore: () -> Unit,
) {
val ownership = uiState.ownership
val subscription = ownership.subscription
UpgradeOwnedHero(ownership = ownership)
if (ownership.hasIap) {
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_owned_iap_title),
icon = Icons.TwoTone.Verified,
modifier = Modifier.testTag(UpgradeScreenTags.GPLAY_OWNED_IAP),
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_owned_iap_body))
}
}
if (subscription != null) {
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_subscription_offer_title),
icon = Icons.TwoTone.Autorenew,
modifier = Modifier.testTag(UpgradeScreenTags.GPLAY_OWNED_SUB),
) {
UpgradeSectionBody(
text = stringResource(
if (subscription.isAutoRenewing) R.string.upgrade_screen_owned_sub_renewing_body
else R.string.upgrade_screen_owned_sub_not_renewing_body
),
)
if (subscription.isAutoRenewing && ownership.hasIap) {
Text(
text = stringResource(R.string.upgrade_screen_owned_both_renewing_warning),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
OutlinedButton(
onClick = onManageSubscription,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_MANAGE_SUB),
) {
Text(stringResource(R.string.upgrade_screen_manage_subscription_action))
}
}
}
if (subscription != null && !ownership.hasIap) {
// The switch path as a visible artifact, not just prose: while the subscription still
// renews, the offer is shown LOCKED with the unlock condition — a renewing subscriber
// must never be able to stack the one-time purchase on an upcoming renewal.
val switchUnlocked = !subscription.isAutoRenewing
UpgradeActionCard {
UpgradeOfferRow(
title = stringResource(R.string.upgrade_screen_iap_offer_title),
price = uiState.iapPrice,
hint = stringResource(
if (switchUnlocked) R.string.upgrade_screen_owned_iap_purchase_note
else R.string.upgrade_screen_owned_iap_locked_note
),
) {
Button(
onClick = onIap,
// Not gated on iapEnabled: prices may have failed to load while the purchase
// itself would work (the billing flow re-queries details on launch).
enabled = switchUnlocked && uiState.busy == null,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_IAP),
) {
if (uiState.busy == BusyOp.IAP) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.upgrade_screen_iap_action))
}
}
}
}
// Framed as a status re-check; support is offered by the failed-restore dialog only.
UpgradeRestoreSection(
title = stringResource(R.string.upgrade_screen_restore_status_title),
body = stringResource(R.string.upgrade_screen_restore_status_body),
onRestore = onRestore,
busy = uiState.busy,
)
}
// The "you have it" moment: mascot and congrats in one hero card at the top of the status
// screen, with the variant (subscription vs one-time) spelled out. The per-purchase cards below
// carry details and actions.
@Composable
private fun UpgradeOwnedHero(
ownership: Ownership,
modifier: Modifier = Modifier,
) {
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_OWNED_HERO),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
UpgradeMascot(size = 56.dp)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.upgrade_screen_owned_hero_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
// The permanent purchase is the meaningful one when both are owned.
// capod's body strings name the app inline (no format arg) — they are among
// the shared ids whose 76 locale translations must stay untouched.
text = stringResource(
if (ownership.hasIap) R.string.upgrade_screen_owned_hero_iap_body
else R.string.upgrade_screen_owned_hero_sub_body,
),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
// Shown on the acquisition view while Pro is active purely via the local grace window. Calm
// reassurance styling, not a warning: the user has lost nothing (yet). Stage 1 confirms Pro is
// intact; stage 2 (after the episode aged past the threshold) explains and offers restore.
@Composable
internal fun UpgradeGraceCard(
showDiagnostics: Boolean,
onRestore: () -> Unit,
modifier: Modifier = Modifier,
busy: BusyOp? = null,
) {
val restoreInProgress = busy == BusyOp.RESTORE
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_grace_title),
icon = Icons.TwoTone.Verified,
modifier = modifier.testTag(UpgradeScreenTags.GPLAY_GRACE),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
// While the episode is young the title says "Confirming…", so the header shows motion to
// match. Once diagnostics appear the copy asks the user to act — a spinner would say
// "still working, wait" and undercut the restore button, so the static icon returns.
leading = if (showDiagnostics) null else {
{
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
.testTag(UpgradeScreenTags.GPLAY_GRACE_SPINNER),
strokeWidth = 2.5.dp,
)
}
},
) {
Text(
text = stringResource(
if (showDiagnostics) R.string.upgrade_screen_grace_body
else R.string.upgrade_screen_grace_body_short
),
style = MaterialTheme.typography.bodyMedium,
)
if (showDiagnostics) {
Button(
onClick = onRestore,
// Any running entitlement action blocks a restore; only a running RESTORE spins.
enabled = busy == null,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_GRACE_RESTORE),
) {
if (restoreInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.upgrade_screen_restore_purchase_action))
}
}
}
}
private fun previewLoadedState(ownership: Ownership) = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.UNAVAILABLE,
subscriptionEnabled = false,
subscriptionPrice = "$12.99",
iapEnabled = !ownership.hasIap,
iapPrice = "$24.99",
ownership = ownership,
)
@Preview2
@Composable
private fun UpgradeOwnershipRenewingSubPreview() {
PreviewWrapper {
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
UpgradeOwnershipContent(
uiState = previewLoadedState(
Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true)),
),
onIap = {},
onManageSubscription = {},
onRestore = {},
)
}
}
}
@Preview2
@Composable
private fun UpgradeOwnershipNonRenewingSubPreview() {
PreviewWrapper {
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
UpgradeOwnershipContent(
uiState = previewLoadedState(
Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
),
onIap = {},
onManageSubscription = {},
onRestore = {},
)
}
}
}
@Preview2
@Composable
private fun UpgradeOwnershipIapPreview() {
PreviewWrapper {
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
UpgradeOwnershipContent(
uiState = previewLoadedState(Ownership(hasIap = true)),
onIap = {},
onManageSubscription = {},
onRestore = {},
)
}
}
}
@Preview2
@Composable
private fun UpgradeGraceCardQuietPreview() {
PreviewWrapper {
UpgradeGraceCard(
showDiagnostics = false,
onRestore = {},
)
}
}
@Preview2
@Composable
private fun UpgradeGraceCardDiagnosticsPreview() {
PreviewWrapper {
UpgradeGraceCard(
showDiagnostics = true,
onRestore = {},
)
}
}
@Preview2
@Composable
private fun UpgradeOwnershipBothRenewingPreview() {
PreviewWrapper {
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
UpgradeOwnershipContent(
uiState = previewLoadedState(
Ownership(hasIap = true, subscription = SubscriptionOwnership(isAutoRenewing = true)),
),
onIap = {},
onManageSubscription = {},
onRestore = {},
)
}
}
}
@@ -0,0 +1,123 @@
package eu.darken.capod.common.upgrade.ui
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Restore
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
// Described restore section, shared by all restore audiences (copy and emphasis differ, wiring
// doesn't). Deliberately NO contact-support action here: escalation is offered only after a
// restore came up empty (the failed-restore dialog), so self-service gets its chance first.
@Composable
internal fun UpgradeRestoreSection(
title: String,
body: String,
onRestore: () -> Unit,
modifier: Modifier = Modifier,
busy: BusyOp? = null,
emphasized: Boolean = false,
restoreTag: String = UpgradeScreenTags.GPLAY_RESTORE,
) {
// Any running entitlement action (purchase included) blocks a restore — they all reconcile the
// same Play account state. Only a running RESTORE shows the spinner.
val restoreInProgress = busy == BusyOp.RESTORE
UpgradeSectionCard(
title = title,
icon = Icons.TwoTone.Restore,
modifier = modifier,
colors = if (emphasized) {
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
} else {
null
},
) {
if (emphasized) {
// Plain Text: the tinted container brings its own content color, the muted
// UpgradeSectionBody tone is for neutral surface cards only.
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
)
} else {
UpgradeSectionBody(text = body)
}
if (emphasized) {
Button(
onClick = onRestore,
enabled = busy == null,
modifier = Modifier
.fillMaxWidth()
.testTag(restoreTag),
) {
RestoreButtonLabel(restoreInProgress = restoreInProgress)
}
} else {
OutlinedButton(
onClick = onRestore,
enabled = busy == null,
modifier = Modifier
.fillMaxWidth()
.testTag(restoreTag),
) {
RestoreButtonLabel(restoreInProgress = restoreInProgress)
}
}
}
}
@Composable
private fun RestoreButtonLabel(restoreInProgress: Boolean) {
if (restoreInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.upgrade_screen_restore_purchase_action))
}
@Preview2
@Composable
private fun UpgradeRestoreSectionPreview() {
PreviewWrapper {
UpgradeRestoreSection(
title = "Already bought Pro?",
body = "Restoring asks Google Play to re-check this app's purchases for the current account.",
onRestore = {},
)
}
}
@Preview2
@Composable
private fun UpgradeRestoreSectionEmphasizedPreview() {
PreviewWrapper {
UpgradeRestoreSection(
title = "Already bought Pro?",
body = "It looks like you upgraded to Pro on this device before.",
onRestore = {},
emphasized = true,
busy = BusyOp.RESTORE,
)
}
}
@@ -0,0 +1,449 @@
package eu.darken.capod.common.upgrade.ui
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome
import androidx.compose.material.icons.twotone.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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
@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 activity = context as? android.app.Activity
// No screen-level resume refresh: MainActivity already refreshes the upgrade repo on every
// activity resume, which covers returning from Play after cancelling the subscription there.
// rememberSaveable, not remember: these are driven by one-shot events that are already consumed
// from the flow, so a rotation while a dialog is up would drop it for good.
var showRestoreFailed by rememberSaveable { mutableStateOf(false) }
var showRestoreInconclusive by rememberSaveable { mutableStateOf(false) }
var showStillRenewing by rememberSaveable { mutableStateOf(false) }
var showCheckFailed by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(vm) {
vm.events.collect { event ->
when (event) {
UpgradeEvents.RestoreSucceeded -> Toast.makeText(
context,
context.getString(R.string.upgrade_screen_restore_success_message),
Toast.LENGTH_LONG,
).show()
UpgradeEvents.RestoreFailed -> showRestoreFailed = true
UpgradeEvents.RestoreInconclusive -> showRestoreInconclusive = true
UpgradeEvents.SubscriptionStillRenewing -> showStillRenewing = true
UpgradeEvents.SubscriptionCheckFailed -> showCheckFailed = true
}
}
}
if (showRestoreFailed) {
RestoreFailedDialog(
onContactSupport = {
showRestoreFailed = false
vm.onContactSupport()
},
onDismiss = { showRestoreFailed = false },
)
}
if (showRestoreInconclusive) {
RestoreInconclusiveDialog(
onRetry = {
showRestoreInconclusive = false
vm.restorePurchase()
},
onDismiss = { showRestoreInconclusive = false },
)
}
if (showStillRenewing) {
AlertDialog(
onDismissRequest = { showStillRenewing = false },
title = { Text(text = stringResource(R.string.upgrade_screen_sub_still_renewing_title)) },
text = { Text(text = stringResource(R.string.upgrade_screen_sub_still_renewing_message)) },
confirmButton = {
TextButton(
onClick = {
showStillRenewing = false
vm.onManageSubscription()
},
) {
Text(text = stringResource(R.string.upgrade_screen_manage_subscription_action))
}
},
dismissButton = {
TextButton(onClick = { showStillRenewing = false }) {
Text(text = stringResource(R.string.general_close_action))
}
},
)
}
if (showCheckFailed) {
AlertDialog(
onDismissRequest = { showCheckFailed = false },
text = { Text(text = stringResource(R.string.upgrade_screen_sub_check_failed_message)) },
confirmButton = {
TextButton(onClick = { showCheckFailed = false }) {
Text(text = stringResource(R.string.general_close_action))
}
},
)
}
val uiState by vm.state.collectAsStateWithLifecycle()
UpgradeScreen(
uiState = uiState,
onIap = { activity?.let { vm.onGoIap(it) } },
onSubscription = { activity?.let { vm.onGoSubscription(it) } },
onSubscriptionTrial = { activity?.let { vm.onGoSubscriptionTrial(it) } },
onRestore = vm::restorePurchase,
onManageSubscription = vm::onManageSubscription,
onRetry = vm::retrySkuQuery,
onNavigateUp = vm::navUp,
)
}
/**
* Shown when Play answered and no purchase was found. Leads with the just-happened live check,
* which is literally true here: non-answers route to [RestoreInconclusiveDialog] instead. This is
* the ONLY contact-support surface — escalation comes after an empty restore, never before.
*/
@Composable
internal fun RestoreFailedDialog(
onContactSupport: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
val checkedMsg = stringResource(R.string.upgrade_screen_restore_checked_message)
val multiAccountHint = stringResource(R.string.upgrade_screen_restore_multiaccount_hint)
val syncHint = stringResource(R.string.upgrade_screen_restore_sync_patience_hint)
val contactHint = stringResource(R.string.upgrade_screen_restore_contact_hint)
AlertDialog(
onDismissRequest = onDismiss,
text = { Text(text = "$checkedMsg\n\n$multiAccountHint\n\n$syncHint\n\n$contactHint") },
confirmButton = {
TextButton(onClick = onContactSupport) {
Text(text = stringResource(R.string.upgrade_screen_contact_support_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_close_action))
}
},
)
}
/**
* Shown when the restore never got an answer (timeout, or a Play error absorbed by grace). Carries
* no multi-account hint and no contact-support action: nothing was established, so both would be
* premature. Retry is the useful move, and `restorePurchase()` is single-flight.
*/
@Composable
internal fun RestoreInconclusiveDialog(
onRetry: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
val inconclusiveMsg = stringResource(R.string.upgrade_screen_restore_inconclusive_message)
val syncHint = stringResource(R.string.upgrade_screen_restore_sync_patience_hint)
AlertDialog(
onDismissRequest = onDismiss,
text = { Text(text = "$inconclusiveMsg\n\n$syncHint") },
confirmButton = {
TextButton(onClick = onRetry) {
Text(text = stringResource(R.string.general_retry_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_close_action))
}
},
)
}
@Composable
internal fun UpgradeScreen(
uiState: GplayUpgradeUiState = GplayUpgradeUiState.Loading,
onIap: () -> Unit = {},
onSubscription: () -> Unit = {},
onSubscriptionTrial: () -> Unit = {},
onRestore: () -> Unit = {},
onManageSubscription: () -> Unit = {},
onRetry: () -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
// Owners get the ownership presentation: no acquisition upsell (pitch, benefits, offers box)
// anywhere — the one-time purchase appears only as the ownership view's own switch offer,
// locked while the subscription still renews.
val loaded = uiState as? GplayUpgradeUiState.Loaded
val ownedState = loaded?.takeIf { it.ownership.ownsAnything }
UpgradeScreenScaffold(
// Grace users are still Pro: they get the status title too — "Get SD Maid SE Pro" on the
// status screen would contradict the rest of the app, which behaves upgraded. The postfix
// is highlighted like the dashboard title does it.
title = if (ownedState != null || loaded?.grace != null) {
upgradeScreenTitle(upgraded = true)
} else {
AnnotatedString(stringResource(R.string.upgrade_capod_label))
},
onNavigateUp = onNavigateUp,
) { paddingValues ->
UpgradeScreenContent(
paddingValues = paddingValues,
contentPadding = PaddingValues(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 32.dp),
) {
if (ownedState == null) {
// Owners get the mascot inside the congrats hero card instead. Once a grace
// episode ages into the diagnostics stage, the mascot joins the mood: unimpressed
// at Google Play, matching the setup card's "needs your attention" face. The young
// episode keeps the happy face — its message is that nothing is wrong.
UpgradeHeader(
mascotSize = 88.dp,
happy = loaded?.grace?.showDiagnostics != true,
)
}
if (ownedState != null) {
UpgradeOwnershipContent(
uiState = ownedState,
onIap = onIap,
onManageSubscription = onManageSubscription,
onRestore = onRestore,
)
} else {
UpgradeAcquisitionContent(
uiState = uiState,
onIap = onIap,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onRestore = onRestore,
onRetry = onRetry,
)
}
}
}
}
@Composable
private fun UpgradeAcquisitionContent(
uiState: GplayUpgradeUiState,
onIap: () -> Unit,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onRestore: () -> Unit,
onRetry: () -> Unit,
) {
val loadedState = uiState as? GplayUpgradeUiState.Loaded
val inGrace = loadedState?.grace != null
loadedState?.grace?.let { grace ->
UpgradeGraceCard(
showDiagnostics = grace.showDiagnostics,
onRestore = onRestore,
busy = loadedState.busy,
)
}
// Grace users never see the pitch (they are Pro, sales copy next to a "still active" card
// reads as a contradiction), and the OFFERS follow the episode age — the client can't tell a
// blip from a lapsed purchase, so time is the arbiter: a young episode (likely self-healing
// blip) shows calm status only, an aged one (likely really gone) adds restore AND the offers,
// so an expired subscriber can switch without waiting out the full grace window.
if (!inGrace) {
UpgradePreambleCard(
text = stringResource(R.string.upgrade_preamble),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
)
if (uiState is GplayUpgradeUiState.Loaded && uiState.wasPreviouslyPro) {
// The targeted returning-buyer nudge: prominent placement and emphasis, and the ONLY
// restore affordance on the screen — a second one below would make the screen feel
// uncertain about its own advice.
UpgradeRestoreSection(
title = stringResource(R.string.upgrade_screen_restore_banner_title),
body = stringResource(R.string.upgrade_screen_restore_banner_body),
onRestore = onRestore,
modifier = Modifier.testTag(UpgradeScreenTags.GPLAY_RESTORE_BANNER),
busy = uiState.busy,
emphasized = true,
restoreTag = UpgradeScreenTags.GPLAY_RESTORE_BANNER_ACTION,
)
}
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_benefits_title),
icon = Icons.TwoTone.AutoAwesome,
) {
UpgradeFeatureList(text = upgradeBenefitsText())
}
}
// During a YOUNG grace episode the offers box is hidden: likely a blip, and offers next to
// "Pro is still active" would contradict it. An aged episode brings them back.
if (!inGrace || loadedState?.grace?.showDiagnostics == true) {
UpgradeOffersBox(
uiState = uiState,
onIap = onIap,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onRetry = onRetry,
)
}
// Restore is account reconciliation, not an offer — its own described section, after the
// offers. Only for plain acquisition: returning buyers get the emphasized section up top
// instead, and grace users' restore is owned by the grace card's two-stage disclosure.
val loadedForRestore = uiState as? GplayUpgradeUiState.Loaded
if (loadedForRestore != null && !loadedForRestore.wasPreviouslyPro && loadedForRestore.grace == null) {
UpgradeRestoreSection(
title = stringResource(R.string.upgrade_screen_restore_banner_title),
body = stringResource(R.string.upgrade_screen_restore_body),
onRestore = onRestore,
busy = loadedForRestore.busy,
)
}
}
// All purchase framing lives inside the offers box (LoadedOffers) — no separate explainer card.
// Each state brings its OWN container: the error state is a full card itself, wrapping it in the
// action card produced a card-in-card.
@Composable
private fun UpgradeOffersBox(
uiState: GplayUpgradeUiState,
onIap: () -> Unit,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onRetry: () -> Unit,
) {
AnimatedContent(
targetState = uiState,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "upgrade-offers",
) { state ->
when (state) {
GplayUpgradeUiState.Loading -> UpgradeActionCard { UpgradeLoadingBlock() }
is GplayUpgradeUiState.Unavailable -> UpgradeInlineStateCard(
title = stringResource(R.string.upgrades_gplay_unavailable_error),
body = stringResource(R.string.upgrade_screen_offers_unavailable_message),
icon = Icons.TwoTone.WarningAmber,
) {
// Play can be slow rather than broken (cold store, first sign-in): let
// the user re-run the offer queries instead of leaving a dead screen.
OutlinedButton(
onClick = onRetry,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_RETRY),
) {
Text(stringResource(R.string.general_retry_action))
}
}
is GplayUpgradeUiState.Loaded -> UpgradeActionCard {
LoadedOffers(
uiState = state,
onIap = onIap,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
)
}
}
}
}
@Preview2
@Composable
private fun UpgradeScreenLoadingPreview() {
PreviewWrapper {
UpgradeScreen(uiState = GplayUpgradeUiState.Loading)
}
}
@Preview2
@Composable
private fun UpgradeScreenLoadedPreview() {
PreviewWrapper {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.TRIAL,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
),
)
}
}
@Preview2
@Composable
private fun UpgradeScreenReturningBuyerPreview() {
PreviewWrapper {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
wasPreviouslyPro = true,
),
)
}
}
@Preview2
@Composable
private fun UpgradeScreenUnavailablePreview() {
PreviewWrapper {
UpgradeScreen(
uiState = GplayUpgradeUiState.Unavailable(
error = RuntimeException("Google Play unavailable"),
),
)
}
}
@@ -0,0 +1,111 @@
package eu.darken.capod.common.upgrade.ui
import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
// Render-state model for the gplay upgrade screen plus the pure mappers that build it. Kept apart
// from both the composables and the ViewModel: previews and tests construct these directly, and
// neither side should have to drag the other in for it.
internal sealed interface GplayUpgradeUiState {
data object Loading : GplayUpgradeUiState
data class Unavailable(
val error: Throwable,
) : GplayUpgradeUiState
data class Loaded(
val subscriptionAction: SubscriptionAction,
val subscriptionEnabled: Boolean,
val subscriptionPrice: String?,
val iapEnabled: Boolean,
val iapPrice: String?,
val ownership: Ownership = Ownership(),
val grace: GraceHint? = null,
val wasPreviouslyPro: Boolean = false,
val busy: BusyOp? = null,
) : GplayUpgradeUiState
}
// The ONE entitlement operation currently running. Purchases and restores talk to the same Play
// account state, so they are mutually exclusive by construction: a single slot (instead of the
// former independent verifying/restoring flags) makes "which one is busy" unambiguous for both the
// arbiter in the ViewModel and the spinner placement in the UI.
internal enum class BusyOp {
IAP,
SUBSCRIPTION,
RESTORE,
}
// Pro is active purely via the local grace window (no owned purchase). Stage 1 shows a quiet
// "still active" confirmation; diagnostics + restore CTA appear once the episode has aged.
internal data class GraceHint(
val showDiagnostics: Boolean,
)
internal data class Ownership(
val hasIap: Boolean = false,
val subscription: SubscriptionOwnership? = null,
) {
val ownsAnything: Boolean
get() = hasIap || subscription != null
}
internal data class SubscriptionOwnership(
val isAutoRenewing: Boolean,
)
internal enum class SubscriptionAction {
TRIAL,
STANDARD,
UNAVAILABLE,
}
// Display-only ownership mapping from the (replayed) upgradeInfo. Conservative: if ANY record for
// the sub SKU still claims auto-renew (e.g. a retained purchase event next to fresher query data),
// treat it as renewing — that can only under-offer the one-time purchase, never enable it wrongly;
// the actual purchase gate re-verifies against a fresh SUBS query in the ViewModel.
internal fun UpgradeRepoGplay.Info.toOwnership() = Ownership(
hasIap = upgrades.any { it.sku == OurSku.Iap.PRO_UPGRADE },
subscription = upgrades
.filter { it.sku == OurSku.Sub.PRO_UPGRADE }
.takeIf { it.isNotEmpty() }
?.let { subs -> SubscriptionOwnership(isAutoRenewing = subs.any { it.purchase.isAutoRenewing }) },
)
internal fun toLoadedState(
iap: SkuDetails?,
sub: SkuDetails?,
ownership: Ownership,
grace: GraceHint? = null,
wasPreviouslyPro: Boolean = false,
busy: BusyOp? = null,
): GplayUpgradeUiState.Loaded {
val iapOffer = iap?.details?.oneTimePurchaseOfferDetails
val subOffer = sub?.details?.subscriptionOfferDetails?.singleOrNull { offer ->
OurSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offer)
}
val subOfferTrial = sub?.details?.subscriptionOfferDetails?.singleOrNull { offer ->
OurSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offer)
}
return GplayUpgradeUiState.Loaded(
subscriptionAction = when {
subOfferTrial != null -> SubscriptionAction.TRIAL
subOffer != null -> SubscriptionAction.STANDARD
else -> SubscriptionAction.UNAVAILABLE
},
// Any running entitlement operation (restore, manual or the invisible already-owned
// recovery, and purchases) pauses the buy actions too — starting a purchase while an
// entitlement is being reconciled just races Play into ITEM_ALREADY_OWNED.
subscriptionEnabled = (subOffer != null || subOfferTrial != null) &&
ownership.subscription == null && busy == null,
subscriptionPrice = subOffer?.pricingPhases?.pricingPhaseList?.lastOrNull()?.formattedPrice,
iapEnabled = iapOffer != null && !ownership.hasIap && busy == null,
iapPrice = iapOffer?.formattedPrice,
ownership = ownership,
grace = grace,
wasPreviouslyPro = wasPreviouslyPro,
busy = busy,
)
}
@@ -0,0 +1,458 @@
package eu.darken.capod.common.upgrade.ui
import android.app.Activity
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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 eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.billing.Sku
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Duration
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
@Suppress("unused") private val handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepoGplay,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider = dispatcherProvider) {
// Route is bound from the Host via bindRoute(); SavedStateHandle.toRoute<>() crashes under Nav3.
private val routeFlow = MutableStateFlow<Nav.Main.Upgrade?>(null)
private var hasShownRepoError: Boolean = false
private var hasShownServiceUnavailableError: Boolean = false
private var hasShownPartialQueryError: Boolean = false
val events = SingleEventFlow<UpgradeEvents>()
fun bindRoute(route: Nav.Main.Upgrade) {
if (routeFlow.value != null) return
routeFlow.value = route
}
init {
routeFlow
.filterNotNull()
.take(1)
.onEach { route ->
// The manage route is the ownership screen — Pro users are its audience, they 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()
}
// ONE arbiter for every entitlement action (both purchase paths and restore). They all talk to
// the same Play account state, so two independent guards let a subscription tap and a restore
// tap run concurrent Play operations against each other.
private val activeOp = MutableStateFlow<BusyOp?>(null)
private val retryTrigger = MutableStateFlow(0)
// Test seam: the diagnostics threshold compares wall-clock time, which coroutine test
// dispatchers can't advance.
internal var clock: () -> Long = { System.currentTimeMillis() }
// Re-evaluates the diagnostics threshold when the episode crosses it: all other combined
// flows are distinct-until-changed and can stay silent across the 24h boundary, which would
// otherwise leave a long-lived ViewModel stuck on the quiet stage.
private val graceTick: Flow<Unit> = upgradeRepo.proUnconfirmedSince
.flatMapLatest { stamp ->
flow {
emit(Unit)
if (stamp > 0L) {
val remaining = stamp + GRACE_DIAGNOSTICS_AFTER_MS - clock()
if (remaining > 0) {
delay(remaining)
emit(Unit)
}
}
}
}
// One aggregate query per retry generation: both SKU lookups run concurrently and land in a
// single Done, so the UI can never combine results from two different retry attempts.
private sealed interface SkuQueries {
data object Pending : SkuQueries
data class Done(
val iap: Result<Collection<SkuDetails>>,
val sub: Result<Collection<SkuDetails>>,
) : SkuQueries
}
private val skuQueries: Flow<SkuQueries> = retryTrigger.flatMapLatest {
flow {
emit(SkuQueries.Pending)
val done = coroutineScope {
val iap = async { querySkuDetails(OurSku.Iap.PRO_UPGRADE) }
val sub = async { querySkuDetails(OurSku.Sub.PRO_UPGRADE) }
SkuQueries.Done(iap = iap.await(), sub = sub.await())
}
emit(done)
}
}
private suspend fun querySkuDetails(sku: Sku): Result<Collection<SkuDetails>> = try {
val details = withTimeoutOrNull(SKU_QUERY_TIMEOUT_MS) { upgradeRepo.querySkus(sku) }
?: throw GplayServiceUnavailableException(RuntimeException("SKU query timed out for ${sku.id}"))
Result.success(details)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "querySkuDetails($sku) failed: ${e.asLog()}" }
Result.failure(e)
}
internal val state: StateFlow<GplayUpgradeUiState> = combine(
skuQueries,
upgradeRepo.upgradeInfo,
upgradeRepo.wasEverPro,
upgradeRepo.proUnconfirmedSince,
graceTick,
activeOp,
upgradeRepo.autoRestoreBusy,
upgradeRepo.purchaseLaunchSku,
) { queries, current, wasEverPro, proUnconfirmedSince, _, vmOp, isAutoRestoring, launchSku ->
val ownership = current.toOwnership()
// Pro without any owned purchase == grace. Stage 1 (quiet "still active" line) shows
// immediately; the diagnostics + restore CTA only once the unconfirmed episode has aged
// past the threshold, so self-healing Play blips never surface them.
val grace = if (current.isPro && !ownership.ownsAnything) {
GraceHint(
showDiagnostics = proUnconfirmedSince > 0L &&
clock() - proUnconfirmedSince >= GRACE_DIAGNOSTICS_AFTER_MS,
)
} else {
null
}
// Owners and grace users don't depend on offer prices: their status and management
// actions render immediately and price problems are not their problem.
val priceIndependent = ownership.ownsAnything || grace != null
val done = queries as? SkuQueries.Done
if (done == null) {
// A new attempt starts a new error episode.
hasShownServiceUnavailableError = false
hasShownPartialQueryError = false
}
// Structural close: entitlement-dependent UI never renders from a pre-reconciliation
// Info — even if fast SKU queries finish before the reconciled Info propagates, an
// unsettled owner must not be flashed acquisition offers. Carve-out: a Done where BOTH
// fresh SKU queries failed is itself a definitive can't-reach-Play outcome and may
// resolve to Unavailable/grace without waiting for the connect loop's failure signal
// (preserves the ~15s bound from the query timeouts during a total Play hang).
val bothQueriesFailed = done != null && done.iap.isFailure && done.sub.isFailure
if (!current.isSettled && !bothQueriesFailed) return@combine GplayUpgradeUiState.Loading
// Acquisition renders with prices like it always has; owners and grace users render
// their status immediately without waiting for prices.
if (done == null && !priceIndependent) return@combine GplayUpgradeUiState.Loading
val iap = done?.iap?.getOrNull()
val sub = done?.sub?.getOrNull()
if (done != null) {
if (iap == null && sub == null) {
val serviceUnavailableError = GplayServiceUnavailableException(
done.iap.exceptionOrNull() ?: RuntimeException("IAP and SUB data request failed.")
)
// Grace users and owners are excluded: during an outage (exactly when grace
// matters) they must keep the Loaded presentation with their status/grace card,
// not an acquisition-style error state or dialog.
if (!priceIndependent) {
// This combine re-runs on every upstream change (e.g. restore progress
// toggling) — emit once per failure episode, not once per recombination.
if (!hasShownServiceUnavailableError) {
hasShownServiceUnavailableError = true
errorEvents.tryEmit(serviceUnavailableError)
}
return@combine GplayUpgradeUiState.Unavailable(serviceUnavailableError)
}
} else {
hasShownServiceUnavailableError = false
// Exactly one product type failed: keep today's behavior — show what's available,
// surface the failure once. Not for owners/grace: price errors aren't their problem.
val partialError = done.iap.exceptionOrNull() ?: done.sub.exceptionOrNull()
if (partialError != null && !priceIndependent) {
if (!hasShownPartialQueryError) {
hasShownPartialQueryError = true
errorEvents.tryEmit(partialError)
}
} else if (partialError == null) {
// Only a SUCCESS resets the flag. A priceIndependent user with a failed query
// must leave it untouched: it may already be true from before they became an
// owner, and resetting would re-emit the same episode if ownership lapses
// again. A new query attempt (Pending above) resets it either way.
hasShownPartialQueryError = false
}
}
if (!current.isPro && current.error != null) {
if (!hasShownRepoError) {
hasShownRepoError = true
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
errorEvents.tryEmit(current.error!!)
}
} else {
hasShownRepoError = false
}
// Diagnosability: distinguishes "Play withheld the trial offer" from "offer matching
// failed" when users report a missing trial (see the no-trial offer body fallback).
sub?.firstOrNull()?.details?.subscriptionOfferDetails?.let { offers ->
log(TAG) { "Subscription offers from Play: ${offers.map { "${it.basePlanId}/${it.offerId}" }}" }
}
}
toLoadedState(
iap = iap?.firstOrNull(),
sub = sub?.firstOrNull(),
ownership = ownership,
grace = grace,
wasPreviouslyPro = wasEverPro && !current.isPro,
// This ViewModel's own action wins; otherwise a launch started elsewhere (previous VM
// instance, e.g. across a rotation — the launch lives on AppScope) or the repo's
// invisible already-owned auto-restore still pauses the entitlement actions here.
busy = vmOp
?: launchSku?.let { if (it is Sku.Subscription) BusyOp.SUBSCRIPTION else BusyOp.IAP }
?: BusyOp.RESTORE.takeIf { isAutoRestoring },
)
}.safeStateIn(
initialValue = GplayUpgradeUiState.Loading,
// Lazily (not WhileSubscribed): keep the billing SKU queries cached for the VM lifetime so
// backgrounding >5s and returning doesn't drop the offer cards back to Loading and re-query.
started = SharingStarted.Lazily,
onError = { error -> GplayUpgradeUiState.Unavailable(error) },
)
// Re-runs the SKU queries after a full "Play unavailable" episode — without this, the Lazily
// cached failure bricked the screen for the whole ViewModel lifetime.
fun retrySkuQuery() {
log(TAG) { "retrySkuQuery()" }
retryTrigger.update { it + 1 }
}
// Acquires the single action slot. Rejects while ANY other entitlement action of this ViewModel
// runs, and while the repo reports a Play launch in flight (which may belong to another VM
// instance — the repo CAS remains the authoritative gate, this only avoids the pointless tap).
private fun acquireOp(op: BusyOp): Boolean {
upgradeRepo.purchaseLaunchSku.value?.let {
log(TAG) { "$op ignored, a billing launch for $it is already in flight" }
return false
}
if (!activeOp.compareAndSet(expect = null, update = op)) {
log(TAG) { "$op ignored, ${activeOp.value} is already in progress" }
return false
}
return true
}
fun onGoIap(activity: Activity) {
log(TAG) { "onGoIap($activity)" }
launch {
// Single-flight: repeated taps must not stack verifications or billing launches.
if (!acquireOp(BusyOp.IAP)) return@launch
try {
// Hard gate against double-billing: verify against a FRESH SUBS-only query — the
// replayed upgradeInfo can be stale or built from partial results. Fails closed:
// no verified "not set to renew" (or no sub at all), no one-time purchase.
val subscriptions = try {
withTimeoutOrNull(VERIFY_TIMEOUT_MS) { upgradeRepo.queryCurrentSubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Subscription verification errored: ${e.asLog()}" }
errorEvents.tryEmit(e)
return@launch
}
when {
subscriptions == null -> {
log(TAG, WARN) { "Subscription verification timed out" }
events.tryEmit(UpgradeEvents.SubscriptionCheckFailed)
}
subscriptions.any { it.isAutoRenewing } -> {
log(TAG, INFO) { "IAP purchase blocked: subscription is still set to renew" }
events.tryEmit(UpgradeEvents.SubscriptionStillRenewing)
}
// Suspends until the Play sheet launch resolved, so the single-flight guard
// covers the whole tap-to-sheet window, not just the verification.
else -> upgradeRepo.launchBillingFlowNow(
activity,
OurSku.Iap.PRO_UPGRADE,
null,
onError = errorEvents::tryEmit,
)
}
} finally {
activeOp.value = null
}
}
}
fun onGoSubscription(activity: Activity) {
log(TAG) { "onGoSubscription($activity)" }
startSubPurchase(activity, OurSku.Sub.PRO_UPGRADE.BASE_OFFER)
}
fun onGoSubscriptionTrial(activity: Activity) {
log(TAG) { "onGoSubscriptionTrial($activity)" }
startSubPurchase(activity, OurSku.Sub.PRO_UPGRADE.TRIAL_OFFER)
}
private fun startSubPurchase(activity: Activity, offer: Sku.Subscription.Offer) {
launch {
if (!acquireOp(BusyOp.SUBSCRIPTION)) return@launch
try {
// launchBillingFlowNow suspends until the launch resolved, so the guard covers the
// whole tap-to-sheet window. The flow itself still runs on AppScope, so closing the
// screen mid-launch doesn't abort the purchase.
upgradeRepo.launchBillingFlowNow(
activity,
OurSku.Sub.PRO_UPGRADE,
offer,
onError = errorEvents::tryEmit,
)
} finally {
activeOp.value = null
}
}
}
fun onManageSubscription() {
log(TAG) { "onManageSubscription()" }
webpageTool.open(PLAY_SUBSCRIPTION_SITE)
}
fun onContactSupport() {
log(TAG) { "onContactSupport()" }
// The guided support form, not a bare mailto: it attaches version and Pro context, which
// is exactly what purchase troubleshooting needs.
navTo(Nav.Settings.ContactSupport)
}
fun restorePurchase() = launch {
// Single-flight: repeated taps while a restore is running (worst case bounded by
// RESTORE_TIMEOUT_MS) must not stack concurrent restores and duplicate result dialogs —
// and a restore must not run alongside a purchase either.
if (!acquireOp(BusyOp.RESTORE)) return@launch
log(TAG) { "restorePurchase()" }
try {
// Minimum visible duration, not a fixed add-on: the pad runs CONCURRENTLY with the
// real Play query, so a fast check gets stretched to a believable length while a slow
// one gains nothing. A sub-second round-trip reads as "nothing was checked" and
// undermines the result — the check is real, this only makes its duration perceptible.
// Manual restores only; the repo's invisible auto-restore must stay fast.
val restored = coroutineScope {
val minVisible = async { delay(RESTORE_MIN_VISIBLE_MS) }
val result = withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
minVisible.await()
result
}
when {
restored == null -> {
// Budget covers connecting, the refresh mutex AND both queries, so a query may
// well have started. All we know is the check didn't finish -- not that Play
// said no. Reporting this as a completed check would send an owner chasing the
// multi-account explanation for what is really a slow or unreachable Play.
log(TAG, WARN) { "Restore purchase timed out" }
events.tryEmit(UpgradeEvents.RestoreInconclusive)
}
restored is UpgradeRepoGplay.RestoreOutcome.Inconclusive -> {
// Play errored and grace kept Pro alive. Same non-answer as a timeout, and the
// user is by definition a recent owner -- the last person to tell that we
// checked and found nothing.
log(TAG, WARN) { "Restore purchase inconclusive: ${restored.cause.asLog()}" }
events.tryEmit(UpgradeEvents.RestoreInconclusive)
}
restored.info.upgrades.isNotEmpty() -> {
log(TAG, INFO) { "Restored purchase :))" }
// Explicit feedback: on the ownership screen a successful restore changes
// nothing visible (the user already is Pro), so silence reads as "broken".
events.tryEmit(UpgradeEvents.RestoreSucceeded)
}
else -> {
// Play answered and had nothing. Includes a grace-only result from a successful
// EMPTY query: Pro may still be active, but the check really did complete, so
// troubleshooting and escalation are warranted.
log(TAG, WARN) { "Restore purchase found no purchases (isPro=${restored.info.isPro})" }
events.tryEmit(UpgradeEvents.RestoreFailed)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Play/billing error (e.g. service unavailable): surface the proper error dialog instead
// of the generic "restore failed" message, so the user can tell the two cases apart.
log(TAG, WARN) { "Restore purchase errored: ${e.asLog()}" }
errorEvents.tryEmit(e)
} finally {
// Reset only after result handling, so the single-flight guard covers the whole action.
activeOp.value = null
}
}
companion object {
private const val RESTORE_TIMEOUT_MS = 15_000L
// Floor for how long a manual restore visibly runs (spinner up, result held back). Long
// enough that the user believes a round-trip to Play happened, short enough not to drag.
internal const val RESTORE_MIN_VISIBLE_MS = 1_500L
private const val VERIFY_TIMEOUT_MS = 10_000L
// The very first billing query after Play sign-in can take >8s (measured 8.5s) while Play
// warms up — 5s produced false "Play unavailable" dialogs on slow-but-healthy stores.
private const val SKU_QUERY_TIMEOUT_MS = 15_000L
// How long a fresh-data-confirmed grace episode must last before the grace card shows its
// diagnostics: long enough that self-healing Play blips stay invisible, short enough to
// leave most of the 7-day subscription grace for the user to act in.
internal val GRACE_DIAGNOSTICS_AFTER_MS = Duration.ofHours(24).toMillis()
// Play's management page for our subscription specifically; harmless without a matching
// sub on the account (Play falls back to the general subscription list).
internal val PLAY_SUBSCRIPTION_SITE =
"https://play.google.com/store/account/subscriptions" +
"?sku=${OurSku.Sub.PRO_UPGRADE.id}&package=${BuildConfigWrap.APPLICATION_ID}"
private val TAG = logTag("Upgrade", "Gplay", "ViewModel")
}
}
@@ -1,342 +0,0 @@
package eu.darken.capod.upgrade.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.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.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
// Test tags for the upgrade screen. Existing values are kept verbatim so the behavioral Compose
// tests keep pointing at the same nodes across the offercard restructure; new surfaces get new tags.
object UpgradeScreenTags {
const val SUB_BUTTON = "upgrade.sub.button"
const val IAP_BUTTON = "upgrade.iap.button"
const val RESTORE_BUTTON = "upgrade.restore.button"
const val RETRY_BUTTON = "upgrade.retry.button"
const val RESTORE_BANNER = "upgrade.restore.banner"
const val RESTORE_BANNER_ACTION = "upgrade.restore.banner.action"
const val OWNER_HERO = "upgrade.owner.hero"
const val OWNER_SUB_CARD = "upgrade.owner.subCard"
const val OWNER_IAP_CARD = "upgrade.owner.iapCard"
const val OWNER_WARNING = "upgrade.owner.bothOwnedWarning"
const val MANAGE_SUB_BUTTON = "upgrade.manageSub.button"
const val SWITCH_CARD = "upgrade.switch.card"
const val SWITCH_BUTTON = "upgrade.switch.button"
const val GRACE_CARD = "upgrade.grace.card"
const val GRACE_RESTORE_BUTTON = "upgrade.grace.restore"
const val DIALOG_STILL_RENEWING = "upgrade.dialog.stillRenewing"
const val DIALOG_CHECK_FAILED = "upgrade.dialog.checkFailed"
const val DIALOG_RESTORE_FAILED = "upgrade.dialog.restoreFailed"
const val CONTACT_SUPPORT_BUTTON = "upgrade.dialog.contactSupport"
const val BENEFITS = "upgrade.benefits"
const val OFFERS = "upgrade.offers"
const val OFFERS_UNAVAILABLE = "upgrade.offers.unavailable"
const val OFFERS_SETTLING = "upgrade.offers.settling"
const val LOADING = "upgrade.loading"
}
// "CAPod Pro" with the postfix highlighted in the upgraded brand color — the same treatment the
// dashboard title uses. Split from the composed resource so translations can reorder the words.
@Composable
internal fun upgradeScreenTitle(): AnnotatedString {
val parts = stringResource(R.string.app_name_pro).split(" ").filter { it.isNotEmpty() }
val highlight = colorResource(R.color.brand_tertiary)
return buildAnnotatedString {
if (parts.size == 2) {
append("${parts[0]} ")
withStyle(SpanStyle(color = highlight, fontWeight = FontWeight.Bold)) { append(parts[1]) }
} else {
append(stringResource(R.string.app_name_pro))
}
}
}
// The screen shell: a plain surface with the floating back arrow (capod convention — the upgrade
// TopAppBar was removed in 7f2b6976 to avoid clipping the header graphic) over a centered,
// width-capped scrolling column. Sections are spaced uniformly; the caller supplies the header.
@Composable
internal fun UpgradeScreenContainer(
onNavigateUp: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.padding(paddingValues)
// widthIn BEFORE fillMaxWidth: reversed, fillMaxWidth would pin the min to
// the full screen and the 560dp cap would never take effect on wide screens.
.widthIn(max = 560.dp)
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
content = content,
)
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
.padding(4.dp),
) {
// Matches capod's app-wide back-button convention (no navigate-up string exists).
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
}
}
}
// The header graphic in a tinted circle. capod has no mascot; splash_graphic2 stands in for it in
// both the acquisition header and the owned hero.
@Composable
internal fun UpgradeHeader(
graphicSize: Dp,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Box(contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(graphicSize + 40.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
) {}
Image(
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = Modifier.size(graphicSize),
)
}
}
}
@Composable
internal fun UpgradeSectionCard(
title: String,
icon: ImageVector,
modifier: Modifier = Modifier,
iconTint: Color = Color.Unspecified,
colors: CardColors? = null,
leading: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val cardColors = colors ?: CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = cardColors,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
UpgradeSectionHeader(title = title, icon = icon, iconTint = iconTint, leading = leading)
content()
}
}
}
@Composable
internal fun UpgradeSectionHeader(
title: String,
icon: ImageVector,
modifier: Modifier = Modifier,
iconTint: Color = Color.Unspecified,
leading: (@Composable () -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (leading != null) {
leading()
} else {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (iconTint == Color.Unspecified) MaterialTheme.colorScheme.primary else iconTint,
)
}
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
internal fun UpgradeSectionBody(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.fillMaxWidth(),
)
}
@Composable
internal fun UpgradeHintText(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier.fillMaxWidth(),
)
}
// Container for the offers block: a raised card that resizes smoothly as offer rows appear/vanish.
@Composable
internal fun UpgradeActionCard(
modifier: Modifier = Modifier,
colors: CardColors? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val cardColors = colors ?: CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = cardColors,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
@Composable
internal fun UpgradeLoadingBlock(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}
// Error-container styled card, used for the "prices couldn't load" fallback.
@Composable
internal fun UpgradeInlineStateCard(
title: String,
body: String,
icon: ImageVector,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit = {},
) {
UpgradeSectionCard(
title = title,
icon = icon,
modifier = modifier,
iconTint = MaterialTheme.colorScheme.onErrorContainer,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
content()
}
}
// Spinner-prefixed button label shared by all busy-capable buttons on this screen.
@Composable
internal fun BusyButtonLabel(busy: Boolean, text: String) {
if (busy) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = text)
}
@@ -1,209 +0,0 @@
package eu.darken.capod.upgrade.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.AutoAwesome
import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
// The acquisition offers card: header, offer rows, an "or" divider and footnote — but only when
// BOTH pricing models actually loaded. capod's subscriptionEnabled/iapEnabled do not encode offer
// availability (both can be true with a null price), so availability drives conditional rendering
// here while the enabled flags drive the busy/settled gating.
@Composable
internal fun LoadedOffers(
state: UpgradeUiState.Loaded,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
) {
UpgradeActionCard(modifier = Modifier.testTag(UpgradeScreenTags.OFFERS)) {
UpgradeSectionHeader(
title = stringResource(R.string.upgrade_screen_offers_title),
icon = Icons.TwoTone.Stars,
)
val showBoth = state.subAvailable && state.iapAvailable
if (state.subAvailable) {
val isTrial = state.subscriptionAction == SubscriptionAction.TRIAL
UpgradeOfferRow(
title = stringResource(R.string.upgrade_screen_subscription_offer_title),
price = state.subscriptionPrice,
hint = stringResource(
if (isTrial) R.string.upgrade_screen_subscription_offer_body
else R.string.upgrade_screen_subscription_offer_body_no_trial
),
) {
Button(
onClick = if (isTrial) onSubscriptionTrial else onSubscription,
enabled = state.subscriptionEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.SUB_BUTTON),
) {
Text(
text = stringResource(
if (isTrial) R.string.upgrade_screen_subscription_trial_action
else R.string.upgrade_screen_subscription_action
),
)
}
}
}
if (showBoth) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.upgrade_screen_offers_or),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
}
if (state.iapAvailable) {
UpgradeOfferRow(
// Acquisition uses the neutral "One-time purchase" title; the switch-flavored
// iap_offer_title copy stays owner-only.
title = stringResource(R.string.upgrade_screen_owned_iap_title),
price = state.iapPrice,
hint = stringResource(R.string.upgrade_screen_iap_offer_body),
) {
OutlinedButton(
onClick = onIap,
enabled = state.iapEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.IAP_BUTTON),
) {
BusyButtonLabel(
busy = state.verificationInProgress,
text = stringResource(R.string.upgrade_screen_iap_action),
)
}
}
}
if (showBoth) {
UpgradeHintText(text = stringResource(R.string.upgrade_screen_offers_body))
}
}
}
// Title and price share one line ("·"-joined: direction-neutral punctuation, not translatable
// copy), terms follow as body text, then the action.
@Composable
internal fun UpgradeOfferRow(
title: String,
price: String?,
modifier: Modifier = Modifier,
hint: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = listOfNotNull(title, price).joinToString(" · "),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
hint?.let { UpgradeSectionBody(text = it) }
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
private data class Benefit(val icon: ImageVector, val textRes: Int)
private 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),
)
// capod-specific icon benefit list (kept over SD Maid's text bullets), wrapped in a section card
// so it joins the offercard visual pattern.
@Composable
internal fun UpgradeBenefitsCard(
modifier: Modifier = Modifier,
) {
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_benefits_title),
icon = Icons.TwoTone.AutoAwesome,
modifier = modifier.testTag(UpgradeScreenTags.BENEFITS),
) {
BENEFITS.forEach { benefit ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = benefit.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(benefit.textRes),
style = MaterialTheme.typography.bodyLarge,
)
}
}
UpgradeHintText(
text = stringResource(R.string.upgrade_benefit_disclaimer),
modifier = Modifier.padding(top = 4.dp),
)
}
}
@@ -1,225 +0,0 @@
package eu.darken.capod.upgrade.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Autorenew
import androidx.compose.material.icons.twotone.Verified
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
// Ownership presentation for users who already own Pro. Subscribers without the one-time purchase
// see the switch offer — LOCKED while the subscription still renews, so buying it can't stack with
// an upcoming renewal.
@Composable
internal fun UpgradeOwnershipContent(
state: UpgradeUiState.Loaded,
onIap: () -> Unit,
onManageSubscription: () -> Unit,
onRestore: () -> Unit,
) {
val ownership = state.ownership
val subscription = ownership.subscription
val restoreEnabled = !state.restoreInProgress && !state.verificationInProgress
UpgradeOwnedHero(ownership = ownership)
if (ownership.hasIap) {
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_owned_iap_title),
icon = Icons.TwoTone.Verified,
modifier = Modifier.testTag(UpgradeScreenTags.OWNER_IAP_CARD),
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_owned_iap_body))
}
}
if (subscription != null) {
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_owned_sub_title),
icon = Icons.TwoTone.Autorenew,
modifier = Modifier.testTag(UpgradeScreenTags.OWNER_SUB_CARD),
) {
UpgradeSectionBody(
text = stringResource(
// No dates: the client can't know expiry/renewal, only intent.
if (subscription.isAutoRenewing) R.string.upgrade_screen_owned_sub_renewing_body
else R.string.upgrade_screen_owned_sub_not_renewing_body
),
)
if (subscription.isAutoRenewing && ownership.hasIap) {
Text(
text = stringResource(R.string.upgrade_screen_owned_both_renewing_warning),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(UpgradeScreenTags.OWNER_WARNING),
)
}
OutlinedButton(
onClick = onManageSubscription,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.MANAGE_SUB_BUTTON),
) {
Text(text = stringResource(R.string.upgrade_screen_manage_subscription_action))
}
}
}
if (subscription != null && !ownership.hasIap) {
// The switch path as a visible artifact, not just prose: while the subscription still
// renews the offer is shown LOCKED with the unlock condition. `iapEnabled` centralizes
// settled/restore/verification/ownership gating; the renewal state adds the lock.
val switchUnlocked = !subscription.isAutoRenewing
UpgradeActionCard(modifier = Modifier.testTag(UpgradeScreenTags.SWITCH_CARD)) {
UpgradeOfferRow(
title = stringResource(R.string.upgrade_screen_iap_offer_title),
price = state.iapPrice,
hint = stringResource(
if (switchUnlocked) R.string.upgrade_screen_owned_iap_purchase_note
else R.string.upgrade_screen_owned_iap_locked_note
),
) {
Button(
onClick = onIap,
enabled = switchUnlocked && state.iapEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.SWITCH_BUTTON),
) {
BusyButtonLabel(
busy = state.verificationInProgress,
text = stringResource(R.string.upgrade_screen_iap_action),
)
}
}
}
}
// Framed as a status re-check; support is offered only by the failed-restore dialog.
UpgradeRestoreSection(
title = stringResource(R.string.upgrade_screen_restore_status_title),
body = stringResource(R.string.upgrade_screen_restore_status_body),
onRestore = onRestore,
restoreInProgress = state.restoreInProgress,
enabled = restoreEnabled,
)
}
// The "you have it" moment: header graphic + congrats in one hero card, with the variant
// (subscription vs one-time) spelled out. The per-purchase cards below carry details and actions.
@Composable
private fun UpgradeOwnedHero(
ownership: Ownership,
modifier: Modifier = Modifier,
) {
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.OWNER_HERO),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Image(
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = Modifier.size(56.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.upgrade_screen_owned_hero_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
// The permanent purchase is the meaningful one when both are owned.
text = stringResource(
if (ownership.hasIap) R.string.upgrade_screen_owned_hero_iap_body
else R.string.upgrade_screen_owned_hero_sub_body
),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
// Shown on the acquisition view while Pro is active purely via the local grace window. Calm
// reassurance, not a warning. Stage 1 confirms Pro is intact (spinner header); stage 2 (after the
// episode aged past the threshold) explains and offers restore (static icon + button).
@Composable
internal fun UpgradeGraceCard(
showDiagnostics: Boolean,
onRestore: () -> Unit,
modifier: Modifier = Modifier,
restoreInProgress: Boolean = false,
verificationInProgress: Boolean = false,
) {
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_grace_title),
icon = Icons.TwoTone.Verified,
modifier = modifier.testTag(UpgradeScreenTags.GRACE_CARD),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
leading = if (showDiagnostics) null else {
{
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.5.dp,
)
}
},
) {
Text(
text = stringResource(
if (showDiagnostics) R.string.upgrade_screen_grace_body
else R.string.upgrade_screen_grace_body_short
),
style = MaterialTheme.typography.bodyMedium,
)
if (showDiagnostics) {
Button(
onClick = onRestore,
enabled = !restoreInProgress && !verificationInProgress,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON),
) {
BusyButtonLabel(
busy = restoreInProgress,
text = stringResource(R.string.upgrade_screen_restore_purchase_action),
)
}
}
}
}
@@ -1,120 +0,0 @@
package eu.darken.capod.upgrade.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Restore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
// Described restore section, shared by all restore audiences (copy and emphasis differ, wiring
// doesn't). `enabled` covers the settled/verification gating; `restoreInProgress` only drives the
// spinner — a button that looks enabled while the ViewModel silently rejects the tap is worse than
// a disabled one. Deliberately NO contact-support action here: escalation is offered only after a
// restore came up empty (RestoreFailedDialog), so self-service gets its chance first.
@Composable
internal fun UpgradeRestoreSection(
title: String,
body: String,
onRestore: () -> Unit,
modifier: Modifier = Modifier,
restoreInProgress: Boolean = false,
enabled: Boolean = true,
emphasized: Boolean = false,
restoreTag: String = UpgradeScreenTags.RESTORE_BUTTON,
) {
UpgradeSectionCard(
title = title,
icon = Icons.TwoTone.Restore,
modifier = modifier,
colors = if (emphasized) {
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
} else {
null
},
) {
if (emphasized) {
// The tinted container brings its own content color; the muted body tone is for
// neutral surface cards only.
Text(text = body, style = MaterialTheme.typography.bodyMedium)
Button(
onClick = onRestore,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.testTag(restoreTag),
) {
BusyButtonLabel(
busy = restoreInProgress,
text = stringResource(R.string.upgrade_screen_restore_purchase_action),
)
}
} else {
UpgradeSectionBody(text = body)
OutlinedButton(
onClick = onRestore,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.testTag(restoreTag),
) {
BusyButtonLabel(
busy = restoreInProgress,
text = stringResource(R.string.upgrade_screen_restore_purchase_action),
)
}
}
}
}
// The only contact-support surface on the screen: it leads with the just-happened live Play check
// (RestoreFailed also fires on timeout, so the copy is hedged), then self-service hints, then the
// escalation. Dismiss uses the generic cancel action (capod has no dedicated dismiss string).
@Composable
internal fun RestoreFailedDialog(
onDismiss: () -> Unit,
onContactSupport: () -> Unit,
) {
AlertDialog(
modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_RESTORE_FAILED),
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action)) },
text = {
Text(
text = listOf(
stringResource(R.string.upgrade_screen_restore_checked_message),
stringResource(R.string.upgrade_screen_restore_multiaccount_hint),
stringResource(R.string.upgrade_screen_restore_sync_patience_hint),
stringResource(R.string.upgrade_screen_restore_contact_hint),
).joinToString("\n\n")
)
},
confirmButton = {
TextButton(
onClick = onContactSupport,
modifier = Modifier.testTag(UpgradeScreenTags.CONTACT_SUPPORT_BUTTON),
) {
Text(text = stringResource(R.string.upgrade_screen_contact_support_action))
}
},
dismissButton = {
// "Close", not "Cancel": this dialog reports a result, it doesn't ask the user to
// confirm or abort an action.
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_close_action))
}
},
)
}
@@ -1,498 +0,0 @@
package eu.darken.capod.upgrade.ui
import android.app.Activity
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
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
@Composable
fun UpgradeScreenHost(
manage: Boolean = false,
vm: UpgradeViewModel = hiltViewModel(),
) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
// Bind the route BEFORE anything else can race the auto-close collector.
LaunchedEffect(manage) { vm.initialize(manage) }
val context = LocalContext.current
val activity = context as? Activity
val state by vm.state.collectAsState()
var showStillRenewingDialog by remember { mutableStateOf(false) }
var showCheckFailedDialog by remember { mutableStateOf(false) }
var showRestoreFailedDialog by remember { mutableStateOf(false) }
val restoreSuccessMessage = stringResource(R.string.upgrade_screen_restore_success_message)
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
UpgradeViewModel.UpgradeEvent.RestoreFailed -> showRestoreFailedDialog = true
UpgradeViewModel.UpgradeEvent.RestoreSucceeded -> {
Toast.makeText(context, restoreSuccessMessage, Toast.LENGTH_LONG).show()
}
UpgradeViewModel.UpgradeEvent.SubscriptionStillRenewing -> showStillRenewingDialog = true
UpgradeViewModel.UpgradeEvent.SubscriptionCheckFailed -> showCheckFailedDialog = true
}
}
}
// Returning from Play's subscription-management page must refresh the renewal state promptly.
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) }
}
UpgradeScreen(
state = state,
onNavigateUp = { vm.navUp() },
onSubscription = { activity?.let { vm.onGoSubscription(it) } },
onSubscriptionTrial = { activity?.let { vm.onGoSubscriptionTrial(it) } },
onIap = { activity?.let { vm.onGoIap(it) } },
onRestore = { vm.restorePurchase() },
onRetry = { vm.retrySkuQuery() },
onManageSubscription = { vm.onManageSubscription() },
)
if (showStillRenewingDialog) {
StillRenewingDialog(
onManage = {
showStillRenewingDialog = false
vm.onManageSubscription()
},
onDismiss = { showStillRenewingDialog = false },
)
}
if (showCheckFailedDialog) {
CheckFailedDialog(onDismiss = { showCheckFailedDialog = false })
}
if (showRestoreFailedDialog) {
RestoreFailedDialog(
onDismiss = { showRestoreFailedDialog = false },
onContactSupport = {
// Dismiss before navigating so the dialog can't linger if this entry is retained.
showRestoreFailedDialog = false
vm.onContactSupport()
},
)
}
}
@Composable
fun UpgradeScreen(
state: UpgradeUiState,
onNavigateUp: () -> Unit,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit,
onManageSubscription: () -> Unit,
onRetry: () -> Unit = {},
) {
UpgradeScreenContainer(onNavigateUp = onNavigateUp) {
UpgradeHeader(graphicSize = 80.dp)
Text(
text = upgradeScreenTitle(),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
state is UpgradeUiState.Loading -> UpgradeLoadingBlock(
modifier = Modifier.testTag(UpgradeScreenTags.LOADING),
)
state is UpgradeUiState.Loaded && state.ownership.ownsAnything -> UpgradeOwnershipContent(
state = state,
onIap = onIap,
onManageSubscription = onManageSubscription,
onRestore = onRestore,
)
state is UpgradeUiState.Loaded && state.grace != null -> GraceContent(
state = state,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onRetry = onRetry,
)
state is UpgradeUiState.Loaded -> AcquisitionContent(
state = state,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onRetry = onRetry,
)
}
}
}
// --- Grace view: Pro is active but Play can't confirm the purchase right now ---
@Composable
private fun GraceContent(
state: UpgradeUiState.Loaded,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit,
onRetry: () -> Unit,
) {
val grace = state.grace ?: return
UpgradeGraceCard(
showDiagnostics = grace.showDiagnostics,
onRestore = onRestore,
restoreInProgress = state.restoreInProgress,
verificationInProgress = state.verificationInProgress,
)
// Quiet phase: no offers, no pitch — a Play hiccup usually resolves itself and showing buy
// buttons to a paying user is confusing. Diagnostics phase: the offers return so an actually
// expired subscriber can switch to the one-time purchase without waiting out the grace window.
if (grace.showDiagnostics) {
UpgradeOffersBox(
state = state,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRetry = onRetry,
)
}
}
// --- Acquisition view: the sales pitch ---
@Composable
private fun AcquisitionContent(
state: UpgradeUiState.Loaded,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit,
onRetry: () -> Unit,
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.upgrade_preamble),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp),
)
}
// Returning buyer: prominent, emphasized, and the ONLY restore affordance — a second one below
// would make the screen feel uncertain about its own advice.
if (state.showRestoreBanner) {
UpgradeRestoreSection(
title = stringResource(R.string.upgrade_screen_restore_banner_title),
body = stringResource(R.string.upgrade_screen_restore_banner_body),
onRestore = onRestore,
modifier = Modifier.testTag(UpgradeScreenTags.RESTORE_BANNER),
restoreInProgress = state.restoreInProgress,
enabled = !state.restoreInProgress && !state.verificationInProgress,
emphasized = true,
restoreTag = UpgradeScreenTags.RESTORE_BANNER_ACTION,
)
}
UpgradeBenefitsCard()
UpgradeOffersBox(
state = state,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRetry = onRetry,
)
// Restore is account reconciliation, not an offer — its own described section, after the
// offers. Only for plain acquisition: returning buyers get the emphasized section up top.
if (!state.showRestoreBanner) {
UpgradeRestoreSection(
title = stringResource(R.string.upgrade_screen_restore_banner_title),
body = stringResource(R.string.upgrade_screen_restore_body),
onRestore = onRestore,
restoreInProgress = state.restoreInProgress,
enabled = !state.restoreInProgress && !state.verificationInProgress,
)
}
}
// The offers card, cross-faded ONLY on the offers phase so ordinary Loaded→Loaded updates
// (settled/restore/verification) recompose in place instead of animating a duplicate-tagged,
// briefly-interactive copy of the whole box.
private enum class OffersPhase { LOADED, SETTLING, NO_OFFERS }
private fun UpgradeUiState.Loaded.offersPhase(): OffersPhase = when {
subAvailable || iapAvailable -> OffersPhase.LOADED
// Before the first billing reconciliation (or while a query is still running) missing offers are
// warm-up, not an outage: on entry upgradeInfo looks like a non-owner until Play answers, so an
// owner would otherwise flash the red "unavailable" card for the split second before their
// status resolves. Show a neutral spinner until we're actually sure Play returned nothing.
!settled || skuQueryInProgress -> OffersPhase.SETTLING
else -> OffersPhase.NO_OFFERS
}
@Composable
private fun UpgradeOffersBox(
state: UpgradeUiState.Loaded,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRetry: () -> Unit,
) {
// Key on the phase but carry the whole state: each content instance must render ITS OWN state
// snapshot, or a crossfade would recompose the outgoing card with the incoming state (empty
// offers fading out, etc.). Same-phase Loaded→Loaded updates share a key and recompose in place.
AnimatedContent(
targetState = state,
contentKey = { it.offersPhase() },
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "upgrade-offers",
modifier = Modifier.fillMaxWidth(),
) { animatedState ->
when (animatedState.offersPhase()) {
OffersPhase.LOADED -> LoadedOffers(
state = animatedState,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
)
OffersPhase.SETTLING -> UpgradeActionCard {
UpgradeLoadingBlock(modifier = Modifier.testTag(UpgradeScreenTags.OFFERS_SETTLING))
}
OffersPhase.NO_OFFERS -> NoOffersCard(
state = animatedState,
onIap = onIap,
onRetry = onRetry,
)
}
}
}
// Cold/slow Play returned no product details. Keep both a fallback purchase action (the billing
// flow re-queries details on launch, so it can still work) AND a Retry that reloads the offers.
@Composable
private fun NoOffersCard(
state: UpgradeUiState.Loaded,
onIap: () -> Unit,
onRetry: () -> Unit,
) {
UpgradeInlineStateCard(
title = stringResource(R.string.upgrade_screen_offers_unavailable_title),
body = stringResource(R.string.upgrade_screen_offers_unavailable_message),
icon = Icons.TwoTone.WarningAmber,
modifier = Modifier.testTag(UpgradeScreenTags.OFFERS_UNAVAILABLE),
) {
Button(
onClick = onIap,
enabled = state.iapEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.IAP_BUTTON),
) {
Text(text = stringResource(R.string.general_upgrade_action))
}
OutlinedButton(
onClick = onRetry,
// Disabled while a query runs so repeated taps can't thrash the query flow.
enabled = !state.skuQueryInProgress,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.RETRY_BUTTON),
) {
Text(text = stringResource(R.string.general_retry_action))
}
}
}
// --- Dialogs kept on the screen (restore-failed lives in UpgradeRestore.kt) ---
@Composable
internal fun StillRenewingDialog(
onManage: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_STILL_RENEWING),
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.upgrade_screen_sub_still_renewing_title)) },
text = { Text(text = stringResource(R.string.upgrade_screen_sub_still_renewing_message)) },
confirmButton = {
TextButton(
onClick = onManage,
modifier = Modifier.testTag(UpgradeScreenTags.MANAGE_SUB_BUTTON),
) {
Text(text = stringResource(R.string.upgrade_screen_manage_subscription_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
@Composable
internal fun CheckFailedDialog(
onDismiss: () -> Unit,
) {
AlertDialog(
modifier = Modifier.testTag(UpgradeScreenTags.DIALOG_CHECK_FAILED),
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.upgrade_screen_sub_check_failed_title)) },
text = { Text(text = stringResource(R.string.upgrade_screen_sub_check_failed_message)) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_done_action))
}
},
)
}
// --- Previews ---
private fun previewLoaded(
ownership: Ownership = Ownership(),
grace: GraceHint? = null,
showRestoreBanner: Boolean = false,
) = UpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.TRIAL,
subscriptionEnabled = true,
subscriptionPrice = "€3.49",
iapEnabled = true,
iapPrice = "€6.49",
ownership = ownership,
grace = grace,
showRestoreBanner = showRestoreBanner,
)
@Preview2
@Composable
private fun UpgradeScreenPreview() = PreviewWrapper {
UpgradeScreen(
state = previewLoaded(),
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
onManageSubscription = {},
)
}
@Preview2
@Composable
private fun UpgradeScreenReturningBuyerPreview() = PreviewWrapper {
UpgradeScreen(
state = previewLoaded(showRestoreBanner = true),
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
onManageSubscription = {},
)
}
@Preview2
@Composable
private fun UpgradeScreenOwnerSubRenewingPreview() = PreviewWrapper {
UpgradeScreen(
state = previewLoaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true)),
),
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
onManageSubscription = {},
)
}
@Preview2
@Composable
private fun UpgradeScreenOwnerIapPreview() = PreviewWrapper {
UpgradeScreen(
state = previewLoaded(ownership = Ownership(hasIap = true)),
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
onManageSubscription = {},
)
}
@Preview2
@Composable
private fun UpgradeScreenGraceDiagnosticsPreview() = PreviewWrapper {
UpgradeScreen(
state = previewLoaded(grace = GraceHint(showDiagnostics = true)),
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
onManageSubscription = {},
)
}
@@ -1,103 +0,0 @@
package eu.darken.capod.upgrade.ui
import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.data.SkuDetails
sealed interface UpgradeUiState {
data object Loading : UpgradeUiState
data class Loaded(
val subscriptionAction: SubscriptionAction,
val subscriptionEnabled: Boolean,
val subscriptionPrice: String?,
val iapEnabled: Boolean,
val iapPrice: String?,
val ownership: Ownership = Ownership(),
val grace: GraceHint? = null,
val showRestoreBanner: Boolean = false,
val settled: Boolean = true,
val restoreInProgress: Boolean = false,
val verificationInProgress: Boolean = false,
// A SKU-detail query is still running. Owners/grace users render the fallback + Retry
// price-independently, so the Retry affordance disables itself while this is true.
val skuQueryInProgress: Boolean = false,
) : UpgradeUiState {
val subAvailable: Boolean get() = subscriptionAction != SubscriptionAction.UNAVAILABLE
val iapAvailable: Boolean get() = iapPrice != null
}
}
// Pro but no owned purchase in the current data — the grace period is carrying the entitlement.
// Quiet at first (a Play hiccup usually resolves itself), diagnostics once the unconfirmed
// episode has aged past the threshold.
data class GraceHint(val showDiagnostics: Boolean)
data class Ownership(
val hasIap: Boolean = false,
val subscription: SubscriptionOwnership? = null,
) {
val ownsAnything: Boolean get() = hasIap || subscription != null
}
data class SubscriptionOwnership(val isAutoRenewing: Boolean)
enum class SubscriptionAction { TRIAL, STANDARD, UNAVAILABLE }
// Conservative: if ANY record for the sub SKU still claims auto-renew, treat it as renewing —
// that can only under-offer the switch to the one-time purchase, and the purchase gate
// re-verifies against a fresh SUBS query before any billing flow starts anyway.
fun UpgradeRepoGplay.Info.toOwnership() = Ownership(
hasIap = upgrades.any { it.sku.id == CapodSku.Iap.PRO_UPGRADE.id },
subscription = upgrades
.filter { it.sku.id == CapodSku.Sub.PRO_UPGRADE.id }
.takeIf { it.isNotEmpty() }
?.let { subs -> SubscriptionOwnership(isAutoRenewing = subs.any { it.purchase.isAutoRenewing }) },
)
// Aggregate result of the one-shot SKU detail queries. `done` distinguishes "queries still
// running" from "queries finished but found nothing" — owners render without waiting either way.
data class SkuQueryState(
val done: Boolean = false,
val iap: SkuDetails? = null,
val sub: SkuDetails? = null,
)
fun toLoadedState(
skus: SkuQueryState,
ownership: Ownership,
grace: GraceHint?,
showRestoreBanner: Boolean,
settled: Boolean,
restoreInProgress: Boolean,
verificationInProgress: Boolean,
skuQueryInProgress: Boolean = false,
): UpgradeUiState.Loaded {
val iapOffer = skus.iap?.details?.oneTimePurchaseOfferDetails
val subOffers = skus.sub?.details?.subscriptionOfferDetails
val baseOffer = subOffers?.firstOrNull { CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(it) }
val trialOffer = subOffers?.firstOrNull { CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(it) }
return UpgradeUiState.Loaded(
subscriptionAction = when {
trialOffer != null -> SubscriptionAction.TRIAL
baseOffer != null -> SubscriptionAction.STANDARD
else -> SubscriptionAction.UNAVAILABLE
},
// `settled` gates all purchase actions until the first billing reconciliation (or its
// bounded fallback): the initially-empty purchase state must not let an owner on a fresh
// install buy the other product before their existing purchase has been seen.
subscriptionEnabled = settled && ownership.subscription == null && !restoreInProgress && !verificationInProgress,
subscriptionPrice = baseOffer?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice,
iapEnabled = settled && !ownership.hasIap && !restoreInProgress && !verificationInProgress,
iapPrice = iapOffer?.formattedPrice,
ownership = ownership,
grace = grace,
showRestoreBanner = showRestoreBanner,
settled = settled,
restoreInProgress = restoreInProgress,
verificationInProgress = verificationInProgress,
skuQueryInProgress = skuQueryInProgress,
)
}
@@ -1,426 +0,0 @@
package eu.darken.capod.upgrade.ui
import android.app.Activity
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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 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.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException
import eu.darken.capod.common.upgrade.core.data.Sku
import eu.darken.capod.common.upgrade.core.data.SkuDetails
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
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
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Duration
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepoGplay,
private val webpageTool: WebpageTool,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
sealed interface UpgradeEvent {
data object RestoreFailed : UpgradeEvent
data object RestoreSucceeded : UpgradeEvent
data object SubscriptionStillRenewing : UpgradeEvent
data object SubscriptionCheckFailed : UpgradeEvent
}
val events = SingleEventFlow<UpgradeEvent>()
private val restoring = MutableStateFlow(false)
// Single-flight guard for ALL purchase actions, held from tap until the Play sheet launch
// resolved — the disabled buttons are best-effort (recomposition lags), this is authoritative.
private val purchaseBusy = MutableStateFlow(false)
// Route binding: null until the host reports whether this is the manage route. The auto-close
// collector waits for it, so a manage visit can never race a premature navUp().
private val manageRoute = MutableStateFlow<Boolean?>(null)
fun initialize(manage: Boolean) {
if (manageRoute.value == null) {
log(TAG) { "initialize(manage=$manage)" }
manageRoute.value = manage
}
}
// Purchase actions stay disabled until the first billing reconciliation of this process (or a
// 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.upgradeInfo.map { it.isSettled }.filter { it },
flow {
delay(SETTLE_FALLBACK_MS)
emit(true)
},
).stateIn(vmScope, SharingStarted.Eagerly, false)
// Bumped by retrySkuQuery() to re-run the aggregate query after a cold/slow-Play failure left
// the offers unavailable — without it the Lazily-cached failure would brick offer selection for
// the whole ViewModel lifetime (only leaving and reopening the screen recovered).
private val retryTrigger = MutableStateFlow(0)
// One aggregate SKU-detail query per retry generation, both types concurrently. Failures
// resolve to null details — owners/grace render price-independently, acquisition users get
// the fallback purchase UI.
private val skuQueries = retryTrigger.flatMapLatest {
flow {
emit(SkuQueryState())
val result = coroutineScope {
val iap = async { querySkuDetailsSafe(CapodSku.Iap.PRO_UPGRADE) }
val sub = async { querySkuDetailsSafe(CapodSku.Sub.PRO_UPGRADE) }
SkuQueryState(done = true, iap = iap.await(), sub = sub.await())
}
emit(result)
}
}.shareIn(vmScope, SharingStarted.Lazily, replay = 1)
private suspend fun querySkuDetailsSafe(sku: Sku): SkuDetails? = try {
withTimeoutOrNull(SKU_QUERY_TIMEOUT_MS) {
upgradeRepo.querySkus(sku).firstOrNull()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to query SKU ${sku.id}: ${e.asLog()}" }
null
}
// Re-runs the SKU queries from the fallback "Retry" affordance. The button that calls this is
// disabled while a query is in flight (skuQueryInProgress), which is the actual thrash guard for
// owners/grace users who keep the fallback visible price-independently; a re-trigger that still
// slips through only cancels-and-restarts the flatMapLatest query (latest wins, each attempt is
// bounded by SKU_QUERY_TIMEOUT_MS), so it can't leak or wedge.
fun retrySkuQuery() {
log(TAG) { "retrySkuQuery()" }
retryTrigger.update { it + 1 }
}
// Manual restore OR the repo's invisible already-owned recovery — either one pauses the buy
// actions, so the two can't be raced against each other from the UI.
private val effectiveRestore: Flow<Boolean> = combine(
restoring,
upgradeRepo.autoRestoreBusy,
) { manual, auto -> manual || auto }
// Re-evaluates the grace presentation when the open episode crosses the diagnostics
// threshold — every other combined flow is distinct-until-changed and would never re-fire.
private val graceTick = upgradeRepo.proUnconfirmedSince
.flatMapLatest { stamp ->
flow {
emit(Unit)
if (stamp > 0L) {
val remaining = stamp + GRACE_DIAGNOSTICS_AFTER_MS - timeSource.currentTimeMillis()
if (remaining > 0) {
delay(remaining)
emit(Unit)
}
}
}
}
private data class BillingState(
val info: UpgradeRepoGplay.Info?,
val wasEverPro: Boolean,
val proUnconfirmedSince: Long,
)
private val billingState = combine(
upgradeRepo.upgradeInfo,
upgradeRepo.wasEverPro,
upgradeRepo.proUnconfirmedSince,
graceTick,
) { info, wasEverPro, unconfirmedSince, _ ->
BillingState(
info = info as? UpgradeRepoGplay.Info,
wasEverPro = wasEverPro,
proUnconfirmedSince = unconfirmedSince,
)
}
val state: StateFlow<UpgradeUiState> = combine(
billingState,
skuQueries,
settled,
effectiveRestore,
purchaseBusy,
) { billing, skus, isSettled, isRestoring, isBusy ->
val info = billing.info
val ownership = info?.toOwnership() ?: Ownership()
val grace = if (info?.isPro == true && !ownership.ownsAnything) {
GraceHint(
showDiagnostics = billing.proUnconfirmedSince > 0L &&
timeSource.currentTimeMillis() - billing.proUnconfirmedSince >= GRACE_DIAGNOSTICS_AFTER_MS
)
} else {
null
}
// Owners and grace users render price-independently: their status view must not degrade
// to a spinner (or an error) just because the SKU pricing queries failed or are slow.
val priceIndependent = ownership.ownsAnything || grace != null
if (!priceIndependent && !skus.done) {
UpgradeUiState.Loading
} else {
toLoadedState(
skus = skus,
ownership = ownership,
grace = grace,
// Hidden while a grace period or an actual purchase keeps the user Pro.
showRestoreBanner = billing.wasEverPro && info?.isPro != true,
settled = isSettled,
restoreInProgress = isRestoring,
verificationInProgress = isBusy,
// Owners/grace users keep the fallback + Retry visible while a query is still
// running; disable Retry then so repeated taps can't thrash the query flow.
skuQueryInProgress = !skus.done,
)
}
}.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), UpgradeUiState.Loading)
init {
// Sales route: close once the user is Pro (purchase completed, or they were Pro all
// along). Manage route: never auto-close — it exists to LOOK at the status.
manageRoute
.filterNotNull()
.flatMapLatest { manage ->
if (manage) emptyFlow() else upgradeRepo.upgradeInfo
}
.filter { it.isPro }
.take(1)
.onEach {
log(TAG) { "User is pro on the sales route, navigating back" }
navUp()
}
.launchIn(vmScope)
}
fun onGoIap(activity: Activity) {
log(TAG, INFO) { "onGoIap()" }
launch {
runExclusive {
// ALWAYS verify against a fresh SUBS-only query, not just for known subscribers:
// the replayed ownership state can be stale or still empty right after process
// start, and a renewing subscriber must never double-buy. Fails closed.
val subscriptions = try {
withTimeoutOrNull(VERIFY_TIMEOUT_MS) { upgradeRepo.queryCurrentSubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Subscription verification failed: ${e.asLog()}" }
errorEvents.emitBlocking(e)
return@runExclusive
}
when {
subscriptions == null -> {
log(TAG, WARN) { "Subscription verification timed out" }
events.tryEmit(UpgradeEvent.SubscriptionCheckFailed)
}
subscriptions.any { it.isAutoRenewing } -> {
log(TAG, INFO) { "Subscription still set to renew -> blocking IAP purchase" }
events.tryEmit(UpgradeEvent.SubscriptionStillRenewing)
}
else -> launchBillingFlow(activity, CapodSku.Iap.PRO_UPGRADE, null)
}
}
}
}
fun onGoSubscription(activity: Activity) {
log(TAG, INFO) { "onGoSubscription()" }
launch {
runExclusive { launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.BASE_OFFER) }
}
}
fun onGoSubscriptionTrial(activity: Activity) {
log(TAG, INFO) { "onGoSubscriptionTrial()" }
launch {
runExclusive { launchBillingFlow(activity, CapodSku.Sub.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER) }
}
}
// Single-flight for purchase actions: the guard is held from the tap until the Play sheet
// launch has resolved, so repeated taps can't stack verification queries or billing flows.
private suspend fun runExclusive(block: suspend () -> Unit) {
// Authoritative gate for the invisible already-owned recovery: button disabling is
// best-effort (recomposition lags a tap), so a subscribe/buy tap dispatched while the silent
// restore runs must be refused here, or it could buy the OTHER product on top of what the
// user already owns (a different-SKU double charge the ITEM_ALREADY_OWNED path won't catch).
if (upgradeRepo.autoRestoreBusy.value) {
log(TAG) { "Purchase action ignored, auto-restore in progress" }
return
}
if (restoring.value) {
log(TAG) { "Purchase action ignored, restore in progress" }
return
}
if (!purchaseBusy.compareAndSet(expect = false, update = true)) {
log(TAG) { "Purchase action ignored, another one is in flight" }
return
}
try {
block()
} finally {
purchaseBusy.value = false
}
}
private suspend fun launchBillingFlow(activity: Activity, sku: Sku, offer: Sku.Subscription.Offer?) {
try {
upgradeRepo.launchBillingFlow(activity, sku, offer)
} catch (e: CancellationException) {
throw e
} catch (e: UserCanceledBillingException) {
// Backing out of the payment sheet is a normal user action, not an error.
log(TAG) { "User canceled the billing flow" }
} catch (e: Exception) {
log(TAG, WARN) { "launchBillingFlow(${sku.id}) failed: ${e.asLog()}" }
errorEvents.emitBlocking(e)
}
}
fun restorePurchase() = launch {
// Don't overlap the invisible already-owned recovery — it is itself a restore.
if (upgradeRepo.autoRestoreBusy.value) {
log(TAG) { "restorePurchase() ignored, auto-restore in progress" }
return@launch
}
// Symmetric to runExclusive: a restore must not overlap an in-flight verification or
// billing launch either, or the user could end up with two result dialogs stacked.
if (purchaseBusy.value) {
log(TAG) { "restorePurchase() ignored, purchase action in flight" }
return@launch
}
// Single-flight: repeated taps while a restore is running (worst case bounded by
// RESTORE_TIMEOUT_MS) must not stack concurrent restores and duplicate result messages.
if (!restoring.compareAndSet(expect = false, update = true)) {
log(TAG) { "restorePurchase() ignored, already in progress" }
return@launch
}
log(TAG, INFO) { "restorePurchase()" }
try {
// Pad the round-trip to a minimum visible duration, CONCURRENTLY with the real query
// (a pad, not an add-on): warm caches can answer instantly, and a spinner that flashes
// for a single frame leaves the user unsure whether anything actually happened.
val restored = coroutineScope {
val minVisible = async { delay(RESTORE_MIN_VISIBLE_MS) }
val result = withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
minVisible.await()
result
}
when {
restored == null -> {
// Play never answered in time; the restore-failed message already suggests the
// purchase may take a while to sync, which fits a timeout too.
log(TAG, WARN) { "Restore purchase timed out" }
events.tryEmit(UpgradeEvent.RestoreFailed)
}
// An actual returned purchase is required — a grace-only isPro means Play still
// couldn't confirm anything, which is not a successful restore.
restored.upgrades.isNotEmpty() -> {
log(TAG, INFO) { "Restored purchase :))" }
events.tryEmit(UpgradeEvent.RestoreSucceeded)
}
else -> {
log(TAG, WARN) { "No pro purchase found" }
events.tryEmit(UpgradeEvent.RestoreFailed)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Play/billing error (e.g. service unavailable): surface the proper error dialog
// instead of the generic "restore failed" toast, so the user can tell the cases apart.
log(TAG, WARN) { "Restore purchase errored: ${e.asLog()}" }
errorEvents.emitBlocking(e)
} finally {
// Reset only after result handling, so the single-flight guard covers the whole action.
restoring.value = false
}
}
fun onManageSubscription() {
log(TAG, INFO) { "onManageSubscription()" }
webpageTool.open(PLAY_SUBSCRIPTION_URL)
}
fun onContactSupport() {
log(TAG, INFO) { "onContactSupport()" }
navTo(Nav.Settings.ContactSupport)
}
fun onResume() {
// Returning from Play (e.g. after cancelling renewal on the Manage page) must reflect the
// new renewal state promptly — the global foreground refresh is throttled to once an
// hour, which would leave the switch offer locked long after the user cancelled.
val current = state.value
val hasSub = (current as? UpgradeUiState.Loaded)?.ownership?.subscription != null
if (!hasSub) return
launch {
try {
withTimeoutOrNull(VERIFY_TIMEOUT_MS) { upgradeRepo.queryCurrentSubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Resume subscription refresh failed: ${e.asLog()}" }
}
}
}
companion object {
internal const val RESTORE_TIMEOUT_MS = 15_000L
// Long enough that the user believes a round-trip to Play happened, short enough not
// to drag.
internal const val RESTORE_MIN_VISIBLE_MS = 1_500L
internal const val VERIFY_TIMEOUT_MS = 10_000L
// The first SKU query after a Play sign-in has been observed to take >8s.
internal const val SKU_QUERY_TIMEOUT_MS = 15_000L
internal const val SETTLE_FALLBACK_MS = 10_000L
internal val GRACE_DIAGNOSTICS_AFTER_MS = Duration.ofHours(24).toMillis()
internal val PLAY_SUBSCRIPTION_URL =
"https://play.google.com/store/account/subscriptions" +
"?sku=${CapodSku.Sub.PRO_UPGRADE.id}&package=${BuildConfigWrap.APPLICATION_ID}"
private val TAG = logTag("Upgrade", "VM")
}
}
+8
View File
@@ -66,4 +66,12 @@
<string name="upgrade_screen_contact_support_action">Contact support</string>
<string name="settings_upgrade_status_label">CAPod Pro</string>
<string name="settings_upgrade_status_description">Your upgrade and purchase status.</string>
<string name="upgrades_gplay_unavailable_error_description">CAPod can\'t connect to Google Play. Is Google Play installed and up to date? Is your Google Account logged in? Try clearing the cache of the Google Play app and rebooting your device.</string>
<string name="upgrades_gplay_internal_error_title">Google Play error</string>
<string name="upgrades_gplay_internal_error_description">An internal Google Play error occurred. Please try the following:\n\n• Restart your device\n• Clear Google Play Store cache\n• Try again later</string>
<string name="upgrades_gplay_network_error_title">Connection error</string>
<string name="upgrades_gplay_network_error_description">Unable to connect to Google Play. Please check your internet connection and try again.</string>
<string name="upgrades_gplay_offer_unavailable_title">Offer unavailable</string>
<string name="upgrades_gplay_offer_unavailable_description">Google Play is currently not offering this upgrade option. Please try again later or check the Google Play Store.</string>
<string name="upgrade_screen_restore_inconclusive_message">The check with Google Play didn\'t finish in time, so your purchase status is still unknown. Nothing has changed on your account.</string>
</resources>
@@ -17,7 +17,11 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.flow.DynamicStateFlow
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
import eu.darken.capod.main.core.CurriculumVitae
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
@@ -36,6 +40,8 @@ class RecorderModule @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
private val installId: InstallId,
private val timeSource: TimeSource,
private val curriculumVitae: CurriculumVitae,
private val upgradeDiagnostics: UpgradeDiagnostics,
) {
@Volatile
@@ -83,8 +89,7 @@ class RecorderModule @Inject constructor(
if (!isResume) {
val startTime = timeSource.currentTimeMillis()
writeTriggerFile(sessionDir, startTime)
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
logRecordingHeader()
this@RecorderModule.currentLogDir = sessionDir
@@ -95,8 +100,7 @@ class RecorderModule @Inject constructor(
persistedLogDir = null,
)
} else {
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
logRecordingHeader()
this@RecorderModule.currentLogDir = sessionDir
@@ -133,6 +137,36 @@ class RecorderModule @Inject constructor(
.launchIn(appScope)
}
// Header lines written into a freshly started recording. Runs AFTER the recorder is live, so
// every read here is diagnostics-only and must never propagate: a failure would abort the state
// update and leave a RUNNING recorder that the module no longer knows about.
private suspend fun logRecordingHeader() {
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
try {
// Billing complaints usually arrive as debug logs: having the lifetime grace/Pro-loss
// history in the header saves a support round-trip.
log(TAG, INFO) { "Pro history: ${curriculumVitae.proHistory()}" }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Diagnostics only — a broken history read must not stop the recorder from starting.
log(TAG, WARN) { "Pro history unavailable: ${e.asLog()}" }
}
// Separate boundary from the block above on purpose: these read different DataStores, and
// the counters above only cover installs new enough to have them. A failure to read one
// must not suppress the other's independent evidence.
try {
upgradeDiagnostics.debugInfo()?.let { log(TAG, INFO) { "Upgrade diagnostics: $it" } }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Upgrade diagnostics unavailable: ${e.asLog()}" }
}
}
private fun createSessionDir(): File {
val timestamp = timeSource.now().atZone(java.time.ZoneOffset.UTC)
.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
@@ -20,7 +20,7 @@ object Nav {
data object TroubleShooter : Main
@Serializable
data class Upgrade(val manage: Boolean = false) : Main
data class Upgrade(val manage: Boolean = false, val forced: Boolean = false) : Main
@Serializable
data class DeviceSettings(val profileId: String) : Main
@@ -0,0 +1,15 @@
package eu.darken.capod.common.upgrade
/**
* Flavor-specific entitlement diagnostics for the debug log header.
*
* Deliberately separate from [UpgradeRepo]: the recorder must be able to read this without
* constructing the billing stack. Resolving [UpgradeRepo] on GPlay would build UpgradeRepoGplay ->
* BillingManager and start its AppScope collectors and connect loop, so simply enabling a debug
* recording would change when billing initializes. Implementations must stay inert.
*/
interface UpgradeDiagnostics {
/** One-line summary for the log header, or null when the flavor has nothing to report. */
suspend fun debugInfo(): String?
}
@@ -0,0 +1,478 @@
package eu.darken.capod.common.upgrade.ui
import androidx.annotation.StringRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.CheckCircle
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
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.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material3.IconButton
import eu.darken.capod.R
internal object UpgradeScreenTags {
const val LOADING = "upgrade_loading"
const val ACTIONS = "upgrade_actions"
const val MASCOT_HAPPY = "upgrade_mascot_happy"
const val MASCOT_GRUMPY = "upgrade_mascot_grumpy"
const val FOSS_SPONSOR = "upgrade_foss_sponsor"
const val FOSS_STATUS_FREE = "upgrade_foss_status_free"
const val FOSS_STATUS_UPGRADED = "upgrade_foss_status_upgraded"
const val FOSS_SHOW_OPTIONS = "upgrade_foss_show_options"
const val FOSS_DONATE = "upgrade_foss_donate"
const val GPLAY_SUBSCRIPTION = "upgrade_gplay_subscription"
const val GPLAY_SUBSCRIPTION_SPINNER = "upgrade_gplay_subscription_spinner"
const val GPLAY_IAP = "upgrade_gplay_iap"
const val GPLAY_IAP_SPINNER = "upgrade_gplay_iap_spinner"
const val GPLAY_RESTORE = "upgrade_gplay_restore"
const val GPLAY_RESTORE_BANNER = "upgrade_gplay_restore_banner"
const val GPLAY_RESTORE_BANNER_ACTION = "upgrade_gplay_restore_banner_action"
const val GPLAY_UNAVAILABLE = "upgrade_gplay_unavailable"
const val GPLAY_RETRY = "upgrade_gplay_retry"
const val GPLAY_OWNED_HERO = "upgrade_gplay_owned_hero"
const val GPLAY_OWNED_IAP = "upgrade_gplay_owned_iap"
const val GPLAY_OWNED_SUB = "upgrade_gplay_owned_sub"
const val GPLAY_MANAGE_SUB = "upgrade_gplay_manage_sub"
const val GPLAY_GRACE = "upgrade_gplay_grace"
const val GPLAY_GRACE_SPINNER = "upgrade_gplay_grace_spinner"
const val GPLAY_GRACE_RESTORE = "upgrade_gplay_grace_restore"
}
// Composed app title with the flavor postfix highlighted in the upgraded color while Pro is
// active — the same treatment the dashboard title card uses.
@Composable
internal fun upgradeScreenTitle(upgraded: Boolean): AnnotatedString {
// capod ships the composed "CAPod Pro" as one translatable string so translations can reorder
// the words; the postfix is the trailing part and gets the upgraded highlight.
val parts = stringResource(R.string.app_name_pro).split(" ").filter { it.isNotEmpty() }
val highlight = colorResource(R.color.brand_tertiary)
return buildAnnotatedString {
if (parts.size == 2) {
append("${parts[0]} ")
if (upgraded) pushStyle(SpanStyle(color = highlight))
append(parts[1])
if (upgraded) pop()
} else {
append(stringResource(R.string.app_name_pro))
}
}
}
@Composable
internal fun UpgradeScreenScaffold(
@StringRes titleRes: Int,
onNavigateUp: () -> Unit,
snackbarHostState: SnackbarHostState? = null,
content: @Composable (PaddingValues) -> Unit,
) = UpgradeScreenScaffold(
title = AnnotatedString(stringResource(titleRes)),
onNavigateUp = onNavigateUp,
snackbarHostState = snackbarHostState,
content = content,
)
@Composable
internal fun UpgradeScreenScaffold(
title: AnnotatedString,
onNavigateUp: () -> Unit,
snackbarHostState: SnackbarHostState? = null,
content: @Composable (PaddingValues) -> Unit,
) {
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
CenterAlignedTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = stringResource(R.string.general_navigate_up_action),
)
}
},
scrollBehavior = scrollBehavior,
)
},
snackbarHost = {
snackbarHostState?.let { SnackbarHost(it) }
},
content = content,
)
}
@Composable
internal fun UpgradeScreenContent(
paddingValues: PaddingValues,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(horizontal = 24.dp, vertical = 24.dp),
content: @Composable ColumnScope.() -> Unit,
) {
Box(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState()),
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 560.dp)
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
content = content,
)
}
}
}
@Composable
internal fun UpgradeMascot(
size: Dp,
modifier: Modifier = Modifier,
happy: Boolean = true,
) {
Image(
// capod has no mascot; splash_graphic2 stands in for it. The happy/grumpy split stays in
// the tags so the mood is still expressed to tests and future artwork.
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = modifier
.size(size)
.testTag(if (happy) UpgradeScreenTags.MASCOT_HAPPY else UpgradeScreenTags.MASCOT_GRUMPY),
)
}
@Composable
internal fun UpgradeHeader(
mascotSize: Dp,
modifier: Modifier = Modifier,
happy: Boolean = true,
) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f),
shape = CircleShape,
) {
UpgradeMascot(
size = mascotSize,
modifier = Modifier.padding(16.dp),
happy = happy,
)
}
}
}
@Composable
internal fun UpgradePreambleCard(
text: String,
modifier: Modifier = Modifier,
colors: CardColors = CardDefaults.elevatedCardColors(),
) {
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = colors,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
}
}
@Composable
internal fun UpgradeSectionCard(
title: String,
icon: ImageVector,
modifier: Modifier = Modifier,
iconTint: Color = Color.Unspecified,
colors: CardColors? = null,
leading: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val cardColors = colors ?: CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = cardColors,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
UpgradeSectionHeader(
title = title,
icon = icon,
iconTint = iconTint,
leading = leading,
)
content()
}
}
}
// The icon+title header every section card leads with — also usable standalone so headerless
// cards (like the offers action card) can join the same visual pattern.
@Composable
internal fun UpgradeSectionHeader(
title: String,
icon: ImageVector,
modifier: Modifier = Modifier,
iconTint: Color = Color.Unspecified,
leading: (@Composable () -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (leading != null) {
leading()
} else {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (iconTint == Color.Unspecified) MaterialTheme.colorScheme.primary else iconTint,
)
}
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
internal fun UpgradeSectionBody(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.fillMaxWidth(),
)
}
// capod ships its upgrade benefits as individually translated ids (76 locales) instead of one
// bulleted blob — rendered through the canonical feature list, with the disclaimer as the trailing
// non-bullet line the list already styles as a plain hint.
@Composable
internal fun upgradeBenefitsText(): String = buildString {
UPGRADE_BENEFITS.forEach { appendLine("${stringResource(it)}") }
append(stringResource(R.string.upgrade_benefit_disclaimer))
}
private val UPGRADE_BENEFITS = listOf(
R.string.upgrade_benefit_themes,
R.string.upgrade_benefit_autoplay,
R.string.upgrade_benefit_popups,
R.string.upgrade_benefit_widgets,
R.string.upgrade_benefit_device_settings,
R.string.upgrade_benefit_device_controls,
R.string.upgrade_benefit_support,
)
@Composable
internal fun UpgradeFeatureList(
text: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
text.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.forEach { line ->
if (line.startsWith("")) {
UpgradeFeatureRow(text = line.removePrefix("").trim())
} else {
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun UpgradeFeatureRow(
text: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
imageVector = Icons.TwoTone.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(18.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
@Composable
internal fun UpgradeHintText(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier.fillMaxWidth(),
)
}
@Composable
internal fun UpgradeActionCard(
modifier: Modifier = Modifier,
colors: CardColors? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val cardColors = colors ?: CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = cardColors,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp)
.animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
@Composable
internal fun UpgradeLoadingBlock(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 18.dp)
.testTag(UpgradeScreenTags.LOADING),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator()
Text(
text = stringResource(R.string.general_progress_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
internal fun UpgradeInlineStateCard(
title: String,
body: String,
icon: ImageVector,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit = {},
) {
UpgradeSectionCard(
title = title,
icon = icon,
modifier = modifier.testTag(UpgradeScreenTags.GPLAY_UNAVAILABLE),
iconTint = MaterialTheme.colorScheme.onErrorContainer,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
content()
}
}
@@ -133,6 +133,14 @@ class MainActivity : Activity2() {
super.onResume()
popUpWindow.isMainActivityVisible = true
popUpWindow.close()
// Per-resume, unthrottled entitlement reconciliation. This is what heals a renewal state
// that changed while the user was away (e.g. cancelling the subscription in Google Play's
// management page — returning to the app resumes this activity). refresh() is bounded and
// swallows its own failures.
lifecycleScope.launch {
log(TAG) { "onResume(): refreshing upgrade info" }
upgradeRepo.refresh()
}
}
override fun onPause() {
+2
View File
@@ -610,4 +610,6 @@
<string name="press_controls_reset_confirm_message">Reset all press mappings to defaults?</string>
<string name="device_settings_noise_control_open_press_controls_action">Open Press Controls</string>
<string name="general_navigate_up_action">Navigate up</string>
<string name="general_progress_loading">Loading</string>
</resources>
@@ -0,0 +1,101 @@
package eu.darken.capod.common.debug.recording.core
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
import eu.darken.capod.main.core.CurriculumVitae
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
import testhelpers.coroutine.TestDispatcherProvider
/**
* The recording header reads two independent diagnostics sources. Both reads happen AFTER the
* recorder is already writing, so a failure in either must never abort the state update — that
* would leave a running recorder the module no longer knows about, i.e. a debug recording that
* can't be stopped or collected.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class RecorderModuleDiagnosticsTest : BaseTest() {
private fun buildModule(
scope: kotlinx.coroutines.CoroutineScope,
curriculumVitae: CurriculumVitae,
upgradeDiagnostics: UpgradeDiagnostics,
) = RecorderModule(
context = ApplicationProvider.getApplicationContext(),
appScope = scope,
dispatcherProvider = TestDispatcherProvider(),
installId = mockk<InstallId>(relaxed = true),
timeSource = SystemTimeSource,
curriculumVitae = curriculumVitae,
upgradeDiagnostics = upgradeDiagnostics,
)
@Test
fun `a failing pro-history read still leaves a tracked recording`() = runTest {
val cv = mockk<CurriculumVitae>()
coEvery { cv.proHistory() } throws IllegalStateException("history unreadable")
val diagnostics = mockk<UpgradeDiagnostics>()
coEvery { diagnostics.debugInfo() } returns "BillingCache(...)"
val module = buildModule(backgroundScope, cv, diagnostics)
module.startRecorder().shouldNotBeNull()
module.state.first { it.isRecording }.currentLogDir.shouldNotBeNull()
// The other source is independent: its evidence must still be collected.
coVerify { diagnostics.debugInfo() }
module.stopRecorder().shouldNotBeNull()
}
@Test
fun `a failing upgrade-diagnostics read still leaves a tracked recording`() = runTest {
val cv = mockk<CurriculumVitae>()
coEvery { cv.proHistory() } returns CurriculumVitae.ProHistory(
lastState = null,
graceEngagedCount = 0,
graceEngagedLast = null,
proLostCount = 0,
proLostLast = null,
)
val diagnostics = mockk<UpgradeDiagnostics>()
coEvery { diagnostics.debugInfo() } throws IllegalStateException("cache unreadable")
val module = buildModule(backgroundScope, cv, diagnostics)
module.startRecorder().shouldNotBeNull()
module.state.first { it.isRecording }.currentLogDir.shouldNotBeNull()
coVerify { cv.proHistory() }
module.stopRecorder().shouldNotBeNull()
}
@Test
fun `both reads failing still leaves a tracked recording`() = runTest {
val cv = mockk<CurriculumVitae>()
coEvery { cv.proHistory() } throws IllegalStateException("history unreadable")
val diagnostics = mockk<UpgradeDiagnostics>()
coEvery { diagnostics.debugInfo() } throws IllegalStateException("cache unreadable")
val module = buildModule(backgroundScope, cv, diagnostics)
val logDir = module.startRecorder()
logDir.exists() shouldBe true
module.state.first { it.isRecording }.currentLogDir shouldBe logDir
module.stopRecorder().shouldNotBeNull()
}
}
@@ -0,0 +1,68 @@
package eu.darken.capod.common.upgrade.core
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.serialization.SerializationModule
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
import java.time.Instant
/**
* The FOSS entitlement of every existing supporter is a record written by an older CAPod version:
* `{"upgradedAt":<millis>,"reason":"foss.upgrade.reason.donated"}`, living in the pre-DataStore
* `settings_foss` SharedPreferences. Reading it goes through the REAL [FossCache] here (not a raw
* Json round-trip), because both halves have to hold: the retained SharedPreferences migration and
* the retained serialization schema. Adopting canonical's `upgradeType` schema would decode these
* records as null and silently strip those supporters' entitlement.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class FossCacheLegacyRecordTest : BaseTest() {
// One test method on purpose: DataStore forbids two active instances on the same file, and
// FossCache is a @Singleton in production.
@Test
fun `a legacy supporter record still grants the entitlement`() = runTest {
val context: Context = ApplicationProvider.getApplicationContext()
context.getSharedPreferences("settings_foss", Context.MODE_PRIVATE)
.edit()
.putString("foss.upgrade", LEGACY_RECORD)
.commit()
val cache = FossCache(context, SerializationModule().json())
cache.upgrade.value().apply {
this shouldNotBe null
this!!.upgradedAt shouldBe Instant.ofEpochMilli(1709553600000)
reason shouldBe FossUpgrade.Reason.DONATED
}
val repo = UpgradeRepoFoss(
// backgroundScope: the repo's shareIn keeps a collector alive for the scope's lifetime.
appScope = backgroundScope,
fossCache = cache,
webpageTool = mockk<WebpageTool>(relaxed = true),
)
repo.upgradeInfo.first().apply {
isPro shouldBe true
upgradedAt shouldBe Instant.ofEpochMilli(1709553600000)
isSettled shouldBe true
}
}
companion object {
private const val LEGACY_RECORD =
"""{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}"""
}
}
@@ -0,0 +1,37 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.upgrade.UpgradeRepo
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class UpgradeRepoFossTest : BaseTest() {
@BeforeEach
fun setup() {
}
@AfterEach
fun teardown() {
}
@Test fun `test upgrade info pro status mapping`() {
UpgradeRepoFoss.Info(
isPro = false,
upgradedAt = null,
).apply {
type shouldBe UpgradeRepo.Type.FOSS
isPro shouldBe false
}
UpgradeRepoFoss.Info(
isPro = true,
upgradedAt = Instant.EPOCH,
).isPro shouldBe true
}
}
@@ -0,0 +1,127 @@
package eu.darken.capod.common.upgrade.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.test.core.app.ApplicationProvider
import androidx.compose.ui.semantics.SemanticsActions
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import org.junit.Assert.assertTrue
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
class FossUpgradeScreenTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
@Test
fun `renders redesigned foss content without duplicated app bar title`() {
composeRule.setUpgradeContent {
UpgradeScreen()
}
composeRule.onAllNodesWithText(context.getString(R.string.settings_upgrade_status_label)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_preamble)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_how_title)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_how_body)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_why_title)).assertCountEquals(1)
// capod renders the benefit list from its own per-item ids (see upgradeBenefitsText()).
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_benefit_themes)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_sponsor_subtitle)).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(1)
}
@Test
fun `sponsor button invokes callback`() {
var clicked = false
composeRule.setUpgradeContent {
UpgradeScreen(onGithubSponsors = { clicked = true })
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(1)
composeRule.onNodeWithTag(UpgradeScreenTags.FOSS_SPONSOR).performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
assertTrue(clicked)
}
}
@Test
fun `free status view shows the status without any pitch content`() {
composeRule.setUpgradeContent {
UpgradeScreen(view = FossUpgradeView.STATUS_FREE)
}
composeRule.onAllNodesWithText(context.getString(R.string.app_name_pro)).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_STATUS_FREE).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_preamble)).assertCountEquals(0)
}
@Test
fun `upgrade options button invokes callback`() {
var clicked = false
composeRule.setUpgradeContent {
UpgradeScreen(view = FossUpgradeView.STATUS_FREE, onShowUpgradeOptions = { clicked = true })
}
composeRule.onNodeWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS)
.performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
assertTrue(clicked)
}
}
@Test
fun `upgraded status view thanks the supporter and offers a recurring donation`() {
composeRule.setUpgradeContent {
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED)
}
composeRule.onAllNodesWithText(context.getString(R.string.app_name_pro)).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_STATUS_UPGRADED).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_supporter_thanks))
.assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_DONATE).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(0)
}
@Test
fun `recurring donation button invokes the sponsors callback`() {
var clicked = false
composeRule.setUpgradeContent {
UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED, onGithubSponsors = { clicked = true })
}
composeRule.onNodeWithTag(UpgradeScreenTags.FOSS_DONATE)
.performSemanticsAction(SemanticsActions.OnClick)
composeRule.runOnIdle {
assertTrue(clicked)
}
}
}
private fun ComposeContentTestRule.setUpgradeContent(
content: @Composable () -> Unit,
) {
setContent {
PreviewWrapper {
content()
}
}
}
@@ -0,0 +1,240 @@
package eu.darken.capod.common.upgrade.ui
import androidx.lifecycle.SavedStateHandle
import eu.darken.capod.R
import eu.darken.capod.common.navigation.NavEvent
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.upgrade.core.FossUpgrade
import eu.darken.capod.common.upgrade.core.UpgradeRepoFoss
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.shouldBe
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowSystemClock
import testhelpers.BaseTest
import testhelpers.TestApplication
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.coroutine.runTest2
import java.time.Duration
import java.time.Instant
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class FossUpgradeViewModelTest : BaseTest() {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun upgradedInfo() = UpgradeRepoFoss.Info(
isPro = true,
upgradedAt = Instant.EPOCH,
upgradeReason = FossUpgrade.Reason.DONATED,
)
private fun mockRepo(
info: MutableStateFlow<UpgradeRepoFoss.Info> = MutableStateFlow(UpgradeRepoFoss.Info()),
): UpgradeRepoFoss = mockk<UpgradeRepoFoss>(relaxed = true).apply {
every { upgradeInfo } returns info
}
private fun buildVm(
repo: UpgradeRepoFoss = mockRepo(),
handle: SavedStateHandle = SavedStateHandle(),
) = UpgradeViewModel(
handle = handle,
dispatcherProvider = TestDispatcherProvider(testDispatcher),
upgradeRepo = repo,
)
@Test
fun `manage route shows the free status to non-upgraded users`() = runTest2(context = testDispatcher) {
val vm = buildVm()
val view = async { vm.state.first { it != null } }
vm.bindRoute(Nav.Main.Upgrade(manage = true))
advanceUntilIdle()
view.await() shouldBe FossUpgradeView.STATUS_FREE
}
@Test
fun `manage route shows the upgraded status to supporters`() = runTest2(context = testDispatcher) {
val vm = buildVm(repo = mockRepo(MutableStateFlow(upgradedInfo())))
val view = async { vm.state.first { it != null } }
vm.bindRoute(Nav.Main.Upgrade(manage = true))
advanceUntilIdle()
view.await() shouldBe FossUpgradeView.STATUS_UPGRADED
}
@Test
fun `default and forced routes show the pitch`() = runTest2(context = testDispatcher) {
val defaultVm = buildVm()
val defaultView = async { defaultVm.state.first { it != null } }
defaultVm.bindRoute(Nav.Main.Upgrade())
val forcedVm = buildVm()
val forcedView = async { forcedVm.state.first { it != null } }
forcedVm.bindRoute(Nav.Main.Upgrade(forced = true))
advanceUntilIdle()
defaultView.await() shouldBe FossUpgradeView.PITCH
forcedView.await() shouldBe FossUpgradeView.PITCH
}
@Test
fun `asking for upgrade options switches the free status to the pitch`() = runTest2(context = testDispatcher) {
val vm = buildVm()
vm.bindRoute(Nav.Main.Upgrade(manage = true))
val freeView = async { vm.state.first { it != null } }
advanceUntilIdle()
freeView.await() shouldBe FossUpgradeView.STATUS_FREE
val pitchView = async { vm.state.first { it == FossUpgradeView.PITCH } }
vm.onShowUpgradeOptions()
advanceUntilIdle()
pitchView.await() shouldBe FossUpgradeView.PITCH
}
@Test
fun `the upgrade-options choice survives process recreation`() = runTest2(context = testDispatcher) {
val handle = SavedStateHandle()
val firstVm = buildVm(handle = handle)
firstVm.bindRoute(Nav.Main.Upgrade(manage = true))
firstVm.onShowUpgradeOptions()
advanceUntilIdle()
// Same handle, fresh ViewModel — as after the process was killed on the pitch.
val recreatedVm = buildVm(handle = handle)
val view = async { recreatedVm.state.first { it != null } }
recreatedVm.bindRoute(Nav.Main.Upgrade(manage = true))
advanceUntilIdle()
view.await() shouldBe FossUpgradeView.PITCH
}
@Test
fun `completing the upgrade lands on the upgraded status even from the pitch`() = runTest2(
context = testDispatcher,
) {
val info = MutableStateFlow(UpgradeRepoFoss.Info())
val vm = buildVm(repo = mockRepo(info))
vm.bindRoute(Nav.Main.Upgrade(manage = true))
vm.onShowUpgradeOptions()
val pitchView = async { vm.state.first { it != null } }
advanceUntilIdle()
pitchView.await() shouldBe FossUpgradeView.PITCH
val upgradedView = async { vm.state.first { it == FossUpgradeView.STATUS_UPGRADED } }
info.value = upgradedInfo()
advanceUntilIdle()
upgradedView.await() shouldBe FossUpgradeView.STATUS_UPGRADED
}
@Test
fun `default route bounces an upgraded user out of the screen`() = runTest2(context = testDispatcher) {
val vm = buildVm(repo = mockRepo(MutableStateFlow(upgradedInfo())))
val navEvents = mutableListOf<NavEvent>()
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.navEvents.collect { navEvents.add(it) } }
vm.bindRoute(Nav.Main.Upgrade())
advanceUntilIdle()
navEvents shouldBe listOf(NavEvent.Up)
collector.cancel()
}
@Test
fun `manage route keeps an upgraded user on the screen`() = runTest2(context = testDispatcher) {
val vm = buildVm(repo = mockRepo(MutableStateFlow(upgradedInfo())))
val navEvents = mutableListOf<NavEvent>()
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.navEvents.collect { navEvents.add(it) } }
vm.bindRoute(Nav.Main.Upgrade(manage = true))
advanceUntilIdle()
navEvents.shouldBeEmpty()
collector.cancel()
}
@Test
fun `a too-quick sponsor return only nudges, it does not upgrade`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
val vm = buildVm(repo = repo)
val nudge = async { vm.snackbarEvents.first() }
vm.goGithubSponsors()
vm.checkSponsorReturn()
advanceUntilIdle()
nudge.await() shouldBe R.string.upgrade_foss_sponsor_returned_early
coVerify(exactly = 0) { repo.persistUpgrade() }
}
@Test
fun `a too-quick sponsor return stays silent for already upgraded users`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo(MutableStateFlow(upgradedInfo()))
val vm = buildVm(repo = repo)
val nudges = mutableListOf<Int>()
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.snackbarEvents.collect { nudges.add(it) } }
vm.goGithubSponsors()
vm.checkSponsorReturn()
advanceUntilIdle()
nudges.shouldBeEmpty()
coVerify(exactly = 0) { repo.persistUpgrade() }
collector.cancel()
}
@Test
fun `a sponsor return after the delay persists the upgrade`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
val vm = buildVm(repo = repo)
val thanks = async { vm.toastEvents.first() }
vm.goGithubSponsors()
ShadowSystemClock.advanceBy(Duration.ofSeconds(6))
vm.checkSponsorReturn()
advanceUntilIdle()
thanks.await() shouldBe R.string.upgrade_foss_supporter_thanks
coVerify(exactly = 1) { repo.persistUpgrade() }
}
}
@@ -0,0 +1,19 @@
package eu.darken.capod.common.upgrade.ui
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
class SponsorReturnTrackerTest {
@Test
fun `resume only counts after background transition`() {
val tracker = SponsorReturnTracker()
tracker.consumeResumeReturn() shouldBe false
tracker.onStop()
tracker.consumeResumeReturn() shouldBe true
tracker.consumeResumeReturn() shouldBe false
}
}
@@ -1,57 +0,0 @@
package eu.darken.capod.upgrade.ui
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.time.Instant
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpgradeScreenFossComposeTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun `supporter status shows the supporter card`() {
composeRule.setContent {
SupporterStatusScreen(
upgradedAt = Instant.parse("2025-11-02T12:00:00Z"),
onNavigateUp = {},
onSponsorPage = {},
)
}
composeRule.onNodeWithTag("upgrade.foss.supporterCard").assertIsDisplayed()
}
@Test
fun `supporter sponsor link fires the plain callback`() {
var sponsorTapped = false
composeRule.setContent {
SupporterStatusScreen(
upgradedAt = Instant.parse("2025-11-02T12:00:00Z"),
onNavigateUp = {},
onSponsorPage = { sponsorTapped = true },
)
}
val label = androidx.test.core.app.ApplicationProvider
.getApplicationContext<android.content.Context>()
.getString(eu.darken.capod.R.string.upgrade_foss_sponsor_again_action)
composeRule.onNode(androidx.compose.ui.test.hasText(label)).performScrollTo().performClick()
sponsorTapped shouldBe true
}
}
@@ -0,0 +1,69 @@
package eu.darken.capod.common.upgrade.core
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.common.datastore.value
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class BillingCacheTest : BaseTest() {
// One test method on purpose: BillingCache is a @Singleton in production, and DataStore
// forbids two active instances on the same file — a second BillingCache in this process
// would crash, not exercise anything real.
@Test
fun `stampLastProState round-trips through the DataStoreValues`() = runTest {
// Real DataStore, no mocks: this catches an encoding mismatch between the raw keys the
// atomic stamp transaction writes and the keys/types the DataStoreValues read.
val cache = BillingCache(ApplicationProvider.getApplicationContext())
cache.lastProStateAt.value() shouldBe 0L
cache.lastProStateSku.value() shouldBe ""
// Defaults on a never-Pro install: this exact triple is what the debug-log header reports
// as "never / unknown-legacy / none", and it's the signal that separates a never-bought
// install from one whose entitlement went missing.
cache.snapshot() shouldBe BillingCache.Snapshot(
lastProStateAt = 0L,
lastProStateSku = "",
proUnconfirmedSince = 0L,
)
cache.stampLastProState(OurSku.Iap.PRO_UPGRADE.id, 1234L)
cache.lastProStateAt.value() shouldBe 1234L
cache.lastProStateSku.value() shouldBe OurSku.Iap.PRO_UPGRADE.id
cache.stampLastProState(OurSku.Sub.PRO_UPGRADE.id, 5678L)
cache.lastProStateAt.value() shouldBe 5678L
cache.lastProStateSku.value() shouldBe OurSku.Sub.PRO_UPGRADE.id
// Occurrence-aware episode clear: a confirmation closes an episode that began at or before
// it, but must leave a NEWER episode intact — a connection failure that occurred after this
// confirmation but was processed out of order opened a still-valid episode.
cache.proUnconfirmedSince.value(4_000L)
cache.stampLastProState(OurSku.Iap.PRO_UPGRADE.id, 5_000L) // confirmation newer than episode
cache.proUnconfirmedSince.value() shouldBe 0L
cache.proUnconfirmedSince.value(9_000L)
cache.stampLastProState(OurSku.Iap.PRO_UPGRADE.id, 8_000L) // confirmation older than episode
cache.proUnconfirmedSince.value() shouldBe 9_000L
// snapshot() must agree with the individual reads. It exists so the debug-log header reads
// all three in ONE DataStore emission: three separate reads can straddle a concurrent
// stampLastProState and report a combination that never existed.
cache.snapshot() shouldBe BillingCache.Snapshot(
lastProStateAt = 8_000L,
lastProStateSku = OurSku.Iap.PRO_UPGRADE.id,
proUnconfirmedSince = 9_000L,
)
}
}
@@ -1,7 +1,7 @@
package eu.darken.capod.common.upgrade.core
import com.android.billingclient.api.ProductDetails
import eu.darken.capod.common.upgrade.core.data.Sku
import eu.darken.capod.common.upgrade.core.billing.Sku
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
@@ -9,29 +9,29 @@ import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class CapodSkuTest : BaseTest() {
class OurSkuTest : BaseTest() {
@Test
fun `PRO_SKUS contains both IAP and subscription`() {
CapodSku.PRO_SKUS.size shouldBe 2
CapodSku.PRO_SKUS.any { it is Sku.Iap } shouldBe true
CapodSku.PRO_SKUS.any { it is Sku.Subscription } shouldBe true
OurSku.PRO_SKUS.size shouldBe 2
OurSku.PRO_SKUS.any { it is Sku.Iap } shouldBe true
OurSku.PRO_SKUS.any { it is Sku.Subscription } shouldBe true
}
@Test
fun `IAP SKU has correct type`() {
CapodSku.Iap.PRO_UPGRADE.type shouldBe Sku.Type.IAP
OurSku.Iap.PRO_UPGRADE.type shouldBe Sku.Type.IAP
}
@Test
fun `Sub SKU has correct id and type`() {
CapodSku.Sub.PRO_UPGRADE.id shouldBe "upgrade.pro"
CapodSku.Sub.PRO_UPGRADE.type shouldBe Sku.Type.SUBSCRIPTION
OurSku.Sub.PRO_UPGRADE.id shouldBe "upgrade.pro"
OurSku.Sub.PRO_UPGRADE.type shouldBe Sku.Type.SUBSCRIPTION
}
@Test
fun `Sub has both base and trial offers`() {
CapodSku.Sub.PRO_UPGRADE.offers.size shouldBe 2
OurSku.Sub.PRO_UPGRADE.offers.size shouldBe 2
}
@Test
@@ -40,7 +40,7 @@ class CapodSkuTest : BaseTest() {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns null
}
CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe true
OurSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe true
}
@Test
@@ -49,7 +49,7 @@ class CapodSkuTest : BaseTest() {
every { basePlanId } returns "wrong-plan"
every { offerId } returns null
}
CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe false
OurSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe false
}
@Test
@@ -58,7 +58,7 @@ class CapodSkuTest : BaseTest() {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns "some-offer"
}
CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe false
OurSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe false
}
@Test
@@ -67,7 +67,7 @@ class CapodSkuTest : BaseTest() {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns "upgrade-pro-baseplan-trial"
}
CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe true
OurSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe true
}
@Test
@@ -76,7 +76,7 @@ class CapodSkuTest : BaseTest() {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns "wrong-offer"
}
CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe false
OurSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe false
}
@Test
@@ -85,6 +85,6 @@ class CapodSkuTest : BaseTest() {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns null
}
CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe false
OurSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe false
}
}
@@ -0,0 +1,55 @@
package eu.darken.capod.common.upgrade.core
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class UpgradeDiagnosticsGplayTest : BaseTest() {
private fun create(snapshot: BillingCache.Snapshot) = UpgradeDiagnosticsGplay(
billingCache = mockk<BillingCache>().apply { coEvery { this@apply.snapshot() } returns snapshot },
)
@Test
fun `a never-pro install is reported as never, not as epoch zero`() = runTest {
// The whole point of this line in the log header is telling "never bought" apart from
// "bought once, entitlement now missing". A raw 0 reads as a 1970 timestamp.
val info = create(
BillingCache.Snapshot(lastProStateAt = 0L, lastProStateSku = "", proUnconfirmedSince = 0L)
).debugInfo()
info shouldBe "BillingCache(lastProStateAt=never, lastProStateSku=unknown/legacy, proUnconfirmedSince=none)"
}
@Test
fun `a confirmed purchase reports an instant and the sku`() = runTest {
val info = create(
BillingCache.Snapshot(
lastProStateAt = 1_700_000_000_000L,
lastProStateSku = OurSku.Iap.PRO_UPGRADE.id,
proUnconfirmedSince = 0L,
)
).debugInfo()
info shouldContain "lastProStateAt=2023-11-14T22:13:20Z"
info shouldContain "lastProStateSku=${OurSku.Iap.PRO_UPGRADE.id}"
info shouldContain "proUnconfirmedSince=none"
}
@Test
fun `an open unconfirmed episode is reported as an instant`() = runTest {
val info = create(
BillingCache.Snapshot(
lastProStateAt = 1_700_000_000_000L,
lastProStateSku = OurSku.Sub.PRO_UPGRADE.id,
proUnconfirmedSince = 1_700_000_500_000L,
)
).debugInfo()
info shouldContain "proUnconfirmedSince=2023-11-14T22:21:40Z"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
package eu.darken.capod.common.upgrade.core.billing
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.Purchase.PurchaseState
import eu.darken.capod.common.upgrade.core.OurSku
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PurchasedSkuTest : BaseTest() {
@Test fun `rendering a purchased sku keeps identifying purchase data out of logs`() {
// Debug recordings are attached to support emails: the purchase token and order ID must not
// travel with them, while the entitlement-diagnosis fields must.
val purchase = mockk<Purchase>().apply {
every { products } returns listOf(OurSku.Iap.PRO_UPGRADE.id)
every { purchaseState } returns PurchaseState.PURCHASED
every { isAcknowledged } returns true
every { isAutoRenewing } returns false
every { purchaseTime } returns 1_000L
every { purchaseToken } returns "SENTINEL-TOKEN"
every { orderId } returns "SENTINEL-ORDER"
}
val rendered = PurchasedSku(OurSku.Iap.PRO_UPGRADE, purchase).toString()
rendered shouldNotContain "SENTINEL-TOKEN"
rendered shouldNotContain "SENTINEL-ORDER"
rendered shouldContain OurSku.Iap.PRO_UPGRADE.id
rendered shouldContain "acknowledged=true"
}
}
@@ -0,0 +1,53 @@
package eu.darken.capod.common.upgrade.core.billing.client
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.Purchase.PurchaseState
import eu.darken.capod.common.upgrade.core.OurSku
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BillingClientExtensionsTest : BaseTest() {
@Test
fun `redacted keeps the diagnostic fields and drops the identifying ones`() {
val purchase = mockk<Purchase>().apply {
every { products } returns listOf(OurSku.Iap.PRO_UPGRADE.id)
every { purchaseState } returns PurchaseState.PURCHASED
every { isAcknowledged } returns true
every { isAutoRenewing } returns false
every { purchaseTime } returns 1234L
}
val rendered = purchase.redacted()
rendered shouldContain OurSku.Iap.PRO_UPGRADE.id
rendered shouldContain "acknowledged=true"
// Never touches the accessors that carry the token / order ID, so they cannot reach a
// support log even indirectly through toString().
rendered shouldNotContain "token"
rendered shouldNotContain "orderId"
}
@Test
fun `redacted never throws, so a diagnostic cannot break the billing path`() {
// These lambdas run on the billing path whenever a recording is active. A formatter that
// throws would replace a real billing result -- or a real billing exception -- with a
// diagnostics failure.
val hostile = mockk<Purchase>().apply {
every { products } throws RuntimeException("nope")
}
hostile.redacted() shouldContain "unreadable"
listOf(hostile).redacted() shouldContain "unreadable"
}
@Test
fun `an empty collection renders as empty, not as null`() {
emptyList<Purchase>().redacted() shouldBe "[]"
}
}
@@ -0,0 +1,978 @@
package eu.darken.capod.common.upgrade.core.billing.client
import android.text.TextUtils
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.AcknowledgePurchaseResponseListener
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResponseListener
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.QueryProductDetailsResult
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.UnfetchedProduct
import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.billing.OfferUnavailableBillingException
import eu.darken.capod.common.upgrade.core.billing.Sku
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
class BillingConnectionTest : BaseTest() {
@BeforeEach
fun setup() {
// launchBillingFlow() hops to the main thread (documented BillingClient contract) --
// unconfined so the hop resolves in place on the test thread.
Dispatchers.setMain(UnconfinedTestDispatcher())
// BillingFlowParams' builders validate through android.text.TextUtils, whose JVM stub throws
// "not mocked". Give it its real semantics instead.
mockkStatic(TextUtils::class)
every { TextUtils.isEmpty(any()) } answers { firstArg<CharSequence?>().isNullOrEmpty() }
}
@AfterEach
fun teardown() {
Dispatchers.resetMain()
unmockkStatic(TextUtils::class)
}
private fun purchase(
time: Long,
token: String = "token-$time",
products: List<String> = listOf(OurSku.Iap.PRO_UPGRADE.id),
acknowledged: Boolean = false,
) = mockk<Purchase>().apply {
every { purchaseTime } returns time
every { purchaseToken } returns token
every { this@apply.products } returns products
every { purchaseState } returns PurchaseState.PURCHASED
every { isAcknowledged } returns acknowledged
}
private fun result(code: Int): BillingResult = BillingResult.newBuilder().setResponseCode(code).build()
private val typeOf: (String) -> Sku.Type? = { id -> OurSku.PRO_SKUS.singleOrNull { it.id == id }?.type }
// region combinePurchaseResults (pure)
@Test fun `combines both product types, newest first`() {
val older = purchase(1_000)
val newer = purchase(2_000)
BillingConnection.combinePurchaseResults(
iap = Result.success(listOf(older)),
sub = Result.success(listOf(newer)),
) shouldBe listOf(newer, older)
}
@Test fun `a single product-type failure does not mask a purchase found by the other`() {
val owned = purchase(1_000)
BillingConnection.combinePurchaseResults(
iap = Result.success(listOf(owned)),
sub = Result.failure(RuntimeException("SUBS query failed")),
) shouldBe listOf(owned)
BillingConnection.combinePurchaseResults(
iap = Result.failure(RuntimeException("IAP query failed")),
sub = Result.success(listOf(owned)),
) shouldBe listOf(owned)
}
@Test fun `both product types empty returns empty`() {
BillingConnection.combinePurchaseResults(
iap = Result.success(emptyList()),
sub = Result.success(emptyList()),
) shouldBe emptyList()
}
@Test fun `nothing found but a query failed rethrows the error`() {
shouldThrow<RuntimeException> {
BillingConnection.combinePurchaseResults(
iap = Result.success(emptyList()),
sub = Result.failure(RuntimeException("SUBS query failed")),
)
}
}
// endregion
// region ReducerState (pure)
@Test fun `events append typed overlay entries and bump the generation`() {
val iapPurchase = purchase(1_000, products = listOf(OurSku.Iap.PRO_UPGRADE.id))
val unknownPurchase = purchase(2_000, products = listOf("some.unknown.product"))
val state = BillingConnection.ReducerState()
.withEvent(listOf(iapPurchase), typeOf)
.withEvent(listOf(unknownPurchase), typeOf)
state.eventGen shouldBe 2L
state.overlay.map { it.gen } shouldBe listOf(1L, 2L)
state.overlay.map { it.type } shouldBe listOf(Sku.Type.IAP, null)
}
@Test fun `a per-type query replaces only its own snapshot`() {
val oldIap = purchase(1_000, token = "iap")
val newSub = purchase(2_000, token = "sub", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
val state = BillingConnection.ReducerState(iapSnapshot = listOf(oldIap))
.withQueryResults(iap = null, sub = listOf(newSub), genAtQueryStart = 0L)
// The failed IAP query keeps the last-known IAP snapshot: a partial refresh must not turn
// "couldn't check" into "confirmed absent".
state.iapSnapshot shouldBe listOf(oldIap)
state.subSnapshot shouldBe listOf(newSub)
state.merged().map { it.purchaseToken } shouldContainExactly listOf("sub", "iap")
}
@Test fun `a per-type query clears its own older overlay entries even on a partial refresh`() {
val iapEvent = purchase(1_000, token = "iap-event")
val state = BillingConnection.ReducerState()
.withEvent(listOf(iapEvent), typeOf) // gen 1
// IAP query (started after the event, gen 1 visible) confirms absence; SUB query failed.
.withQueryResults(iap = emptyList(), sub = null, genAtQueryStart = 1L)
state.overlay shouldBe emptyList()
state.merged() shouldBe emptyList()
}
@Test fun `an event that raced the query survives its commit`() {
val racedEvent = purchase(1_000, token = "raced")
val state = BillingConnection.ReducerState()
// Query started at gen 0, event arrived while it was in flight (gen 1).
.withEvent(listOf(racedEvent), typeOf)
.withQueryResults(iap = emptyList(), sub = emptyList(), genAtQueryStart = 0L)
// The query began before the purchase existed — its empty result must not erase it.
state.merged().map { it.purchaseToken } shouldBe listOf("raced")
}
@Test fun `untyped overlay entries only fall to a complete refresh`() {
val unknown = purchase(1_000, token = "unknown", products = listOf("some.unknown.product"))
val base = BillingConnection.ReducerState().withEvent(listOf(unknown), typeOf) // gen 1
// Partial refresh (SUB failed): the unknown-type entry cannot be attributed, it stays.
base.withQueryResults(iap = emptyList(), sub = null, genAtQueryStart = 1L)
.merged().map { it.purchaseToken } shouldBe listOf("unknown")
// Complete refresh: authoritative for everything the queries could have seen.
base.withQueryResults(iap = emptyList(), sub = emptyList(), genAtQueryStart = 1L)
.merged() shouldBe emptyList()
}
@Test fun `duplicate purchase tokens dedup with the overlay winning`() {
// A surviving overlay entry is newer than the snapshot by construction — e.g. a purchase
// event whose type-query hasn't re-run yet carries fresher (un)acknowledged state.
val snapshotVersion = purchase(1_000, token = "same", acknowledged = true)
val eventVersion = purchase(1_000, token = "same", acknowledged = false)
val state = BillingConnection.ReducerState(iapSnapshot = listOf(snapshotVersion))
.withEvent(listOf(eventVersion), typeOf)
val merged = state.merged()
merged.size shouldBe 1
merged.single().isAcknowledged shouldBe false
}
@Test fun `a covering query supersedes the event representation of the same purchase`() {
val eventVersion = purchase(1_000, token = "same", acknowledged = false)
val queriedVersion = purchase(1_000, token = "same", acknowledged = true)
val state = BillingConnection.ReducerState()
.withEvent(listOf(eventVersion), typeOf) // gen 1
// Query started after the event (genAtQueryStart = 1): its result is fresher.
.withQueryResults(iap = listOf(queriedVersion), sub = emptyList(), genAtQueryStart = 1L)
state.merged().single().isAcknowledged shouldBe true
}
// endregion
// region refreshPurchases + reactive flow (integration)
// A client whose purchase queries answer synchronously, in call order (INAPP first, SUBS
// second — refreshPurchases starts them in that order on the single-threaded test dispatcher).
private fun clientReturning(vararg responses: Pair<BillingResult, List<Purchase>>): BillingClient {
val queue = ArrayDeque(responses.toList())
return mockk<BillingClient>().apply {
every { queryPurchasesAsync(any<QueryPurchasesParams>(), any()) } answers {
val (result, purchases) = queue.removeFirst()
secondArg<PurchasesResponseListener>().onQueryPurchasesResponse(result, purchases.toMutableList())
}
}
}
@Test fun `a partial-failure refresh still reaches the reactive purchases flow`() = runTest2 {
// A purchase found by one product type while the other query fails must not leave the
// reactive purchases/upgradeInfo chain starved — otherwise a successful restore would
// never actually unlock the app.
val owned = purchase(1_000)
val connection = BillingConnection(
client = clientReturning(
result(BillingResponseCode.OK) to listOf(owned),
result(BillingResponseCode.ERROR) to emptyList(),
),
)
val refresh = connection.refreshPurchases()
refresh.purchases shouldBe listOf(owned)
refresh.isComplete shouldBe false
connection.purchases.first() shouldBe listOf(owned)
}
@Test fun `a failed sibling query keeps the previous refresh's snapshot for its type`() = runTest2 {
val iapOwned = purchase(1_000, token = "iap")
val subOwned = purchase(2_000, token = "sub", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
val connection = BillingConnection(
client = clientReturning(
// Refresh 1: both succeed, IAP owned.
result(BillingResponseCode.OK) to listOf(iapOwned),
result(BillingResponseCode.OK) to emptyList(),
// Refresh 2: IAP query fails, SUB finds a purchase.
result(BillingResponseCode.ERROR) to emptyList(),
result(BillingResponseCode.OK) to listOf(subOwned),
),
)
connection.refreshPurchases().isComplete shouldBe true
val second = connection.refreshPurchases()
// The IAP purchase from refresh 1 is retained — its query failing is not proof of absence.
second.isComplete shouldBe false
second.purchases.map { it.purchaseToken } shouldContainExactly listOf("sub", "iap")
connection.purchases.first().map { it.purchaseToken } shouldContainExactly listOf("sub", "iap")
}
@Test fun `a complete refresh clears event purchases it could have seen`() = runTest2 {
// Refund case: the purchase arrived via event, then a complete refresh confirms it's gone —
// the stale event must not keep it alive in the reactive flow.
val connection = BillingConnection(
client = clientReturning(
result(BillingResponseCode.OK) to emptyList(),
result(BillingResponseCode.OK) to emptyList(),
),
)
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(purchase(1_000)))
val refresh = connection.refreshPurchases()
refresh.purchases shouldBe emptyList()
connection.purchases.first() shouldBe emptyList()
}
@Test fun `an event arriving while the query is in flight survives the commit`() = runTest2 {
val pendingListeners = mutableListOf<PurchasesResponseListener>()
val client = mockk<BillingClient>().apply {
every { queryPurchasesAsync(any<QueryPurchasesParams>(), any()) } answers {
pendingListeners.add(secondArg())
}
}
val connection = BillingConnection(client = client)
val refresh = async(start = CoroutineStart.UNDISPATCHED) { connection.refreshPurchases() }
runCurrent()
pendingListeners.size shouldBe 2
// Purchase event lands while both queries are still in flight.
val racedPurchase = purchase(1_000, token = "raced")
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(racedPurchase))
pendingListeners.forEach { it.onQueryPurchasesResponse(result(BillingResponseCode.OK), mutableListOf()) }
runCurrent()
// The queries began before the purchase existed — empty results must not erase it.
refresh.await().purchases.map { it.purchaseToken } shouldBe listOf("raced")
connection.purchases.first().map { it.purchaseToken } shouldBe listOf("raced")
}
@Test fun `verified absence survives a failed sibling query`() = runTest2 {
// The IAP query successfully confirms the purchase is GONE (refund) while the SUB query
// fails: the refresh reports the couldn't-verify error, but the verified absence must
// still have been committed — otherwise repeated SUB failures retain Pro indefinitely.
val owned = purchase(1_000)
val connection = BillingConnection(
client = clientReturning(
// Refresh 1: IAP owned, both types succeed.
result(BillingResponseCode.OK) to listOf(owned),
result(BillingResponseCode.OK) to emptyList(),
// Refresh 2: IAP verified empty, SUB fails.
result(BillingResponseCode.OK) to emptyList(),
result(BillingResponseCode.ERROR) to emptyList(),
),
)
connection.refreshPurchases().purchases shouldBe listOf(owned)
shouldThrow<Exception> { connection.refreshPurchases() }
connection.purchases.first() shouldBe emptyList()
}
@Test fun `a refresh commits exactly one reactive emission`() = runTest2 {
val connection = BillingConnection(
client = clientReturning(
result(BillingResponseCode.OK) to listOf(purchase(1_000)),
result(BillingResponseCode.OK) to listOf(
purchase(2_000, products = listOf(OurSku.Sub.PRO_UPGRADE.id))
),
),
)
val emissions = mutableListOf<Collection<Purchase>>()
backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
connection.purchases.collect { emissions.add(it) }
}
connection.refreshPurchases()
runCurrent()
// Both per-type results land in ONE committed state — observers never see an intermediate
// combination that refreshPurchases() didn't return.
emissions.size shouldBe 1
}
@Test fun `concurrent refreshes are serialized`() = runTest2 {
val pendingListeners = mutableListOf<PurchasesResponseListener>()
val client = mockk<BillingClient>().apply {
every { queryPurchasesAsync(any<QueryPurchasesParams>(), any()) } answers {
pendingListeners.add(secondArg())
}
}
val connection = BillingConnection(client = client)
val first = async(start = CoroutineStart.UNDISPATCHED) { connection.refreshPurchases() }
val second = async(start = CoroutineStart.UNDISPATCHED) { connection.refreshPurchases() }
runCurrent()
// Only the first refresh may query; the second waits on the mutex instead of racing its
// commit against a possibly newer result.
pendingListeners.size shouldBe 2
val owned = purchase(1_000)
pendingListeners.forEach { it.onQueryPurchasesResponse(result(BillingResponseCode.OK), mutableListOf(owned)) }
runCurrent()
first.await().purchases shouldBe listOf(owned)
pendingListeners.size shouldBe 4
pendingListeners.drop(2).forEach {
it.onQueryPurchasesResponse(result(BillingResponseCode.OK), mutableListOf(owned))
}
runCurrent()
second.await().purchases shouldBe listOf(owned)
}
@Test fun `purchase failures are delivered as events, repeats included`() = runTest2 {
val connection = BillingConnection(client = mockk())
val alreadyOwned = result(BillingResponseCode.ITEM_ALREADY_OWNED)
connection.onPurchasesUpdated(alreadyOwned, null)
connection.onPurchasesUpdated(alreadyOwned, null)
// Two identical back-to-back failures both arrive — event semantics, no conflation.
val received = connection.purchaseFailures.take(2).toList()
received.map { it.responseCode } shouldBe
listOf(BillingResponseCode.ITEM_ALREADY_OWNED, BillingResponseCode.ITEM_ALREADY_OWNED)
}
@Test fun `a failure event does not evict a fresh purchase event`() = runTest2 {
// Failures and successes travel separately: a reopened-sheet USER_CANCELED must not
// overwrite (or conflate away) a fresh purchase that no query snapshot contains yet.
val connection = BillingConnection(
client = clientReturning(
result(BillingResponseCode.OK) to emptyList(),
result(BillingResponseCode.OK) to emptyList(),
),
)
connection.refreshPurchases() // empty snapshot, predates the purchase
val owned = purchase(1_000)
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(owned))
connection.onPurchasesUpdated(result(BillingResponseCode.USER_CANCELED), null)
connection.purchases.first() shouldBe listOf(owned)
connection.purchaseFailures.first().responseCode shouldBe BillingResponseCode.USER_CANCELED
}
@Test fun `a pending purchase never surfaces as owned or as fresh data`() = runTest2 {
val pending = mockk<Purchase>().apply {
every { purchaseState } returns PurchaseState.PENDING
every { purchaseTime } returns 1_000L
every { purchaseToken } returns "pending"
every { this@apply.products } returns listOf(OurSku.Iap.PRO_UPGRADE.id)
}
val connection = BillingConnection(
client = clientReturning(
result(BillingResponseCode.OK) to emptyList(),
result(BillingResponseCode.OK) to emptyList(),
),
)
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(pending))
connection.refreshPurchases()
connection.purchases.first() shouldBe emptyList()
// Only the refresh's own emission — the PENDING event never produced one.
val update = connection.freshUpdates.first()
update.purchases shouldBe emptyList()
update.isFullSnapshot shouldBe true
}
@Test fun `fresh updates arrive in commit order`() = runTest2 {
// The event's emission precedes the covering refresh's: a consumer stamping the Pro grace
// period can never process a superseded event AFTER the query that cleared it.
val owned = purchase(1_000)
val connection = BillingConnection(
client = clientReturning(
result(BillingResponseCode.OK) to emptyList(),
result(BillingResponseCode.OK) to emptyList(),
),
)
val beforeCommit = System.currentTimeMillis()
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(owned))
connection.refreshPurchases() // complete, clears the event
val updates = connection.freshUpdates.take(2).toList()
updates[0].purchases shouldBe listOf(owned)
updates[0].isFullSnapshot shouldBe false
// Each update is stamped with its commit time (wall-clock), not left unset.
(updates[0].occurredAt >= beforeCommit) shouldBe true
updates[1].purchases shouldBe emptyList()
updates[1].isFullSnapshot shouldBe true
}
@Test fun `fresh updates carry only query-confirmed data, never retained stale purchases`() = runTest2 {
val iapOwned = purchase(1_000, token = "iap")
val connection = BillingConnection(
client = clientReturning(
// Refresh 1: both succeed, IAP owned.
result(BillingResponseCode.OK) to listOf(iapOwned),
result(BillingResponseCode.OK) to emptyList(),
// Refresh 2: IAP fails (stale snapshot retained), SUB verified empty.
result(BillingResponseCode.ERROR) to emptyList(),
result(BillingResponseCode.OK) to emptyList(),
),
)
connection.refreshPurchases()
// Refresh 2 found nothing fresh AND a query failed: it reports the couldn't-verify error —
// after committing what the SUB query did confirm.
shouldThrow<Exception> { connection.refreshPurchases() }
val updates = connection.freshUpdates.take(2).toList()
updates[0].purchases shouldBe listOf(iapOwned)
updates[0].isFullSnapshot shouldBe true
// The retained IAP purchase is still in the reactive view, but it is NOT fresh Play data —
// re-emitting it would keep re-stamping the grace window without a real round-trip.
updates[1].purchases shouldBe emptyList()
updates[1].isFullSnapshot shouldBe false
connection.purchases.first().map { it.purchaseToken } shouldBe listOf("iap")
}
@Test fun `an empty complete refresh racing a purchase event is not a full snapshot`() = runTest2 {
// A user buys while a refresh is in flight: both queries verify empty (they started before
// the purchase existed), but the surviving event means this refresh does NOT prove total
// absence — a full-snapshot claim here would start a false unconfirmed-grace episode for
// the just-purchased user.
val pendingListeners = mutableListOf<PurchasesResponseListener>()
val client = mockk<BillingClient>().apply {
every { queryPurchasesAsync(any<QueryPurchasesParams>(), any()) } answers {
pendingListeners.add(secondArg())
}
}
val connection = BillingConnection(client = client)
val refresh = async(start = CoroutineStart.UNDISPATCHED) { connection.refreshPurchases() }
runCurrent()
pendingListeners.size shouldBe 2
val raced = purchase(1_000, token = "raced")
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(raced))
pendingListeners.forEach { it.onQueryPurchasesResponse(result(BillingResponseCode.OK), mutableListOf()) }
runCurrent()
refresh.await()
val updates = connection.freshUpdates.take(2).toList()
updates[0].purchases shouldBe listOf(raced)
updates[0].isFullSnapshot shouldBe false
updates[1].purchases shouldBe emptyList()
updates[1].isFullSnapshot shouldBe false
}
@Test fun `a hanging sku query callback cannot defeat the caller's timeout`() = runTest2 {
val client = mockk<BillingClient>().apply {
every { queryProductDetailsAsync(any(), any()) } answers { /* Play never calls back */ }
}
val connection = BillingConnection(client = client)
// With a non-cancellable suspension this would hang past the deadline until Play answered;
// suspendCancellableCoroutine lets the timeout fire on time.
val outcome = withTimeoutOrNull(1_000) {
connection.querySkus(OurSku.Iap.PRO_UPGRADE)
}
outcome shouldBe null
}
// endregion
// region querySkus + offer resolution
private fun productDetails(
id: String,
type: String = BillingClient.ProductType.INAPP,
offers: List<ProductDetails.SubscriptionOfferDetails>? = null,
) = mockk<ProductDetails>(relaxed = true).apply {
every { productId } returns id
every { productType } returns type
every { subscriptionOfferDetails } returns offers
}
private fun offerDetails(
offer: Sku.Subscription.Offer,
token: String = "offer-token",
) = mockk<ProductDetails.SubscriptionOfferDetails>(relaxed = true).apply {
every { basePlanId } returns offer.basePlanId
every { offerId } returns offer.offerId
every { offerToken } returns token
}
// Play's QueryProductDetailsParams rejects a product list that mixes INAPP and SUBS, so an
// "omitted sku" fixture needs a second sku of the SAME type as the one Play does answer with.
private object OtherIap : Sku.Iap {
override val id: String = "eu.darken.capod.iap.other"
}
private fun unfetchedProduct(
id: String,
status: Int,
type: String = BillingClient.ProductType.SUBS,
) = mockk<UnfetchedProduct>(relaxed = true).apply {
every { productId } returns id
every { statusCode } returns status
every { productType } returns type
}
private fun skuClient(
queryResponse: BillingResult = result(BillingResponseCode.OK),
details: List<ProductDetails> = emptyList(),
unfetched: List<UnfetchedProduct> = emptyList(),
launchResult: BillingResult = result(BillingResponseCode.OK),
): BillingClient {
val queryResult = mockk<QueryProductDetailsResult>().apply {
every { productDetailsList } returns details
every { unfetchedProductList } returns unfetched
}
return mockk<BillingClient>().apply {
every { queryProductDetailsAsync(any(), any()) } answers {
secondArg<ProductDetailsResponseListener>().onProductDetailsResponse(queryResponse, queryResult)
}
every { launchBillingFlow(any(), any()) } returns launchResult
}
}
@Test fun `an OK response with no details is an offer problem, not a raw state exception`() = runTest2 {
val connection = BillingConnection(client = skuClient(details = emptyList()))
// Play omitting the product entirely used to surface as IllegalStateException -> bug report
// plus an unlocalized dialog.
shouldThrow<OfferUnavailableBillingException> {
connection.querySkus(OurSku.Iap.PRO_UPGRADE)
}.sku shouldBe OurSku.Iap.PRO_UPGRADE
}
@Test fun `a requested sku omitted from the response throws for that sku`() = runTest2 {
// Iterating the response instead of the request would silently return only the details Play
// did send and never notice that the second requested sku is missing.
val connection = BillingConnection(
client = skuClient(details = listOf(productDetails(OurSku.Iap.PRO_UPGRADE.id))),
)
shouldThrow<OfferUnavailableBillingException> {
connection.querySkus(OurSku.Iap.PRO_UPGRADE, OtherIap)
}.sku shouldBe OtherIap
}
@Test fun `duplicate details for one requested sku are ambiguous, never guessed`() = runTest2 {
val connection = BillingConnection(
client = skuClient(
details = listOf(
productDetails(OurSku.Iap.PRO_UPGRADE.id),
productDetails(OurSku.Iap.PRO_UPGRADE.id),
),
),
)
shouldThrow<OfferUnavailableBillingException> {
connection.querySkus(OurSku.Iap.PRO_UPGRADE)
}.sku shouldBe OurSku.Iap.PRO_UPGRADE
}
@Test fun `an unrequested row is skipped and does not satisfy the omitted request`() = runTest2 {
val connection = BillingConnection(
client = skuClient(details = listOf(productDetails("some.other.product"))),
)
shouldThrow<OfferUnavailableBillingException> {
connection.querySkus(OurSku.Iap.PRO_UPGRADE)
}.sku shouldBe OurSku.Iap.PRO_UPGRADE
}
@Test fun `duplicate request entries are deduped instead of failing`() = runTest2 {
val connection = BillingConnection(
client = skuClient(details = listOf(productDetails(OurSku.Iap.PRO_UPGRADE.id))),
)
val details = connection.querySkus(OurSku.Iap.PRO_UPGRADE, OurSku.Iap.PRO_UPGRADE)
details.map { it.sku } shouldBe listOf(OurSku.Iap.PRO_UPGRADE)
}
@Test fun `ITEM_UNAVAILABLE at query time maps to the offer problem`() = runTest2 {
val connection = BillingConnection(
client = skuClient(queryResponse = result(BillingResponseCode.ITEM_UNAVAILABLE)),
)
shouldThrow<OfferUnavailableBillingException> {
connection.querySkus(OurSku.Sub.PRO_UPGRADE)
}.sku shouldBe OurSku.Sub.PRO_UPGRADE
}
@Test fun `other query failures keep the generic client exception`() = runTest2 {
val connection = BillingConnection(client = skuClient(queryResponse = result(BillingResponseCode.ERROR)))
shouldThrow<BillingClientException> { connection.querySkus(OurSku.Iap.PRO_UPGRADE) }
}
@Test fun `an unfetched merchandising status surfaces as an offer problem`() = runTest2 {
val connection = BillingConnection(
client = skuClient(
unfetched = listOf(
unfetchedProduct(OurSku.Sub.PRO_UPGRADE.id, UnfetchedProduct.StatusCode.NO_ELIGIBLE_OFFER),
),
),
)
shouldThrow<OfferUnavailableBillingException> {
connection.querySkus(OurSku.Sub.PRO_UPGRADE)
}.sku shouldBe OurSku.Sub.PRO_UPGRADE
}
@Test fun `an invalid product id format stays on the reportable path`() = runTest2 {
// Our own configuration defect: this one SHOULD reach the bug report, unlike the
// merchandising states.
val connection = BillingConnection(
client = skuClient(
unfetched = listOf(
unfetchedProduct(
OurSku.Sub.PRO_UPGRADE.id,
UnfetchedProduct.StatusCode.INVALID_PRODUCT_ID_FORMAT,
),
),
),
)
shouldThrow<BillingClientException> {
connection.querySkus(OurSku.Sub.PRO_UPGRADE)
}.result.responseCode shouldBe BillingResponseCode.DEVELOPER_ERROR
}
@Test fun `a withheld offer at launch time is an offer problem, not a NoSuchElement`() = runTest2 {
// Play returns the subscription but not the offer we want (revoked/withheld): the strict
// single() used to blow up with an unmapped NoSuchElementException.
val connection = BillingConnection(
client = skuClient(
details = listOf(
productDetails(
id = OurSku.Sub.PRO_UPGRADE.id,
type = BillingClient.ProductType.SUBS,
offers = listOf(offerDetails(OurSku.Sub.PRO_UPGRADE.BASE_OFFER)),
),
),
),
)
val error = shouldThrow<OfferUnavailableBillingException> {
connection.launchBillingFlow(
activity = mockk(),
sku = OurSku.Sub.PRO_UPGRADE,
targetOffer = OurSku.Sub.PRO_UPGRADE.TRIAL_OFFER,
)
}
error.sku shouldBe OurSku.Sub.PRO_UPGRADE
error.offer shouldBe OurSku.Sub.PRO_UPGRADE.TRIAL_OFFER
}
@Test fun `duplicate offer rows at launch time are ambiguous, never guessed`() = runTest2 {
val connection = BillingConnection(
client = skuClient(
details = listOf(
productDetails(
id = OurSku.Sub.PRO_UPGRADE.id,
type = BillingClient.ProductType.SUBS,
offers = listOf(
offerDetails(OurSku.Sub.PRO_UPGRADE.BASE_OFFER, token = "token-a"),
offerDetails(OurSku.Sub.PRO_UPGRADE.BASE_OFFER, token = "token-b"),
),
),
),
),
)
shouldThrow<OfferUnavailableBillingException> {
connection.launchBillingFlow(
activity = mockk(),
sku = OurSku.Sub.PRO_UPGRADE,
targetOffer = OurSku.Sub.PRO_UPGRADE.BASE_OFFER,
)
}.offer shouldBe OurSku.Sub.PRO_UPGRADE.BASE_OFFER
}
@Test fun `a missing subscriptionOfferDetails list at launch time is an offer problem`() = runTest2 {
val connection = BillingConnection(
client = skuClient(
details = listOf(
productDetails(
id = OurSku.Sub.PRO_UPGRADE.id,
type = BillingClient.ProductType.SUBS,
offers = null,
),
),
),
)
shouldThrow<OfferUnavailableBillingException> {
connection.launchBillingFlow(
activity = mockk(),
sku = OurSku.Sub.PRO_UPGRADE,
targetOffer = OurSku.Sub.PRO_UPGRADE.BASE_OFFER,
)
}
}
@Test fun `ITEM_UNAVAILABLE from the launch result maps to the offer problem`() = runTest2 {
val connection = BillingConnection(
client = skuClient(
details = listOf(productDetails(OurSku.Iap.PRO_UPGRADE.id)),
launchResult = result(BillingResponseCode.ITEM_UNAVAILABLE),
),
)
shouldThrow<OfferUnavailableBillingException> {
connection.launchBillingFlow(mockk(), OurSku.Iap.PRO_UPGRADE, null)
}.sku shouldBe OurSku.Iap.PRO_UPGRADE
}
@Test fun `other launch failures keep the generic client exception`() = runTest2 {
val connection = BillingConnection(
client = skuClient(
details = listOf(productDetails(OurSku.Iap.PRO_UPGRADE.id)),
launchResult = result(BillingResponseCode.DEVELOPER_ERROR),
),
)
shouldThrow<BillingClientException> {
connection.launchBillingFlow(mockk(), OurSku.Iap.PRO_UPGRADE, null)
}
}
// endregion
// region querySubscriptions (pre-purchase gate)
@Test fun `querySubscriptions returns fresh subs and keeps the iap snapshot intact`() = runTest2 {
val iapOwned = purchase(1_000, token = "iap")
val subOwned = purchase(2_000, token = "sub", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
val client = clientReturning(
// Refresh: IAP owned, no subs yet.
result(BillingResponseCode.OK) to listOf(iapOwned),
result(BillingResponseCode.OK) to emptyList(),
// SUBS-only gate query: sub found.
result(BillingResponseCode.OK) to listOf(subOwned),
)
val connection = BillingConnection(client = client)
connection.refreshPurchases()
val gateView = connection.querySubscriptions()
gateView shouldBe listOf(subOwned)
// The gate must have queried SUBS, not INAPP (clientReturning ignores the params, so this
// would otherwise go unnoticed). zza() is the params' only product-type accessor — a
// billing library upgrade renaming it breaks this line loudly at compile time.
verify(exactly = 2) {
client.queryPurchasesAsync(match<QueryPurchasesParams> { it.zza() == BillingClient.ProductType.SUBS }, any())
}
// The SUBS-only commit updates the reactive view WITHOUT disturbing the IAP snapshot —
// wiping it would briefly un-Pro a one-time-purchase owner.
connection.purchases.first().map { it.purchaseToken } shouldContainExactly listOf("sub", "iap")
val updates = connection.freshUpdates.take(2).toList()
// Partial by definition: it proves what the SUBS query found, never absence of the rest.
updates[1].purchases shouldBe listOf(subOwned)
updates[1].isFullSnapshot shouldBe false
}
@Test fun `a failed querySubscriptions propagates and commits nothing`() = runTest2 {
val iapOwned = purchase(1_000, token = "iap")
val subOwned = purchase(2_000, token = "sub", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
val connection = BillingConnection(
client = clientReturning(
result(BillingResponseCode.OK) to listOf(iapOwned),
result(BillingResponseCode.OK) to listOf(subOwned),
result(BillingResponseCode.ERROR) to emptyList(),
),
)
val freshUpdates = mutableListOf<BillingConnection.FreshUpdate>()
backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
connection.freshUpdates.collect { freshUpdates.add(it) }
}
connection.refreshPurchases()
// Fail-closed contract: the gate must see the error, not an empty "no subscriptions".
shouldThrow<BillingClientException> { connection.querySubscriptions() }
runCurrent()
// No commit and no fresh emission from the failed query — only the refresh's own. Both
// snapshots survive, including the SUB one the failed query was about.
connection.purchases.first().map { it.purchaseToken } shouldContainExactly listOf("sub", "iap")
freshUpdates.size shouldBe 1
}
@Test fun `querySubscriptions clears an older sub overlay it could have seen`() = runTest2 {
// The sub arrived via purchase event, then the user refunded/cancelled it away: the gate
// query verifies empty and must supersede the stale event — otherwise the ghost sub keeps
// blocking the one-time purchase.
val subEvent = purchase(1_000, token = "sub-event", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
val connection = BillingConnection(
client = clientReturning(result(BillingResponseCode.OK) to emptyList()),
)
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(subEvent))
connection.querySubscriptions() shouldBe emptyList()
connection.purchases.first() shouldBe emptyList()
}
@Test fun `querySubscriptions prefers a racing event's renewal state for the same token`() = runTest2 {
// The stale query result says the sub no longer renews, but a purchase event that landed
// while the query was in flight says it does (user just re-subscribed): the gate must see
// the overlay version, or the fail-closed double-billing check lets the buy through.
val queried = purchase(1_000, token = "same", products = listOf(OurSku.Sub.PRO_UPGRADE.id)).apply {
every { isAutoRenewing } returns false
}
val raced = purchase(1_000, token = "same", products = listOf(OurSku.Sub.PRO_UPGRADE.id)).apply {
every { isAutoRenewing } returns true
}
val pendingListeners = mutableListOf<PurchasesResponseListener>()
val client = mockk<BillingClient>().apply {
every { queryPurchasesAsync(any<QueryPurchasesParams>(), any()) } answers {
pendingListeners.add(secondArg())
}
}
val connection = BillingConnection(client = client)
val gate = async(start = CoroutineStart.UNDISPATCHED) { connection.querySubscriptions() }
runCurrent()
pendingListeners.size shouldBe 1
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(raced))
pendingListeners.single().onQueryPurchasesResponse(result(BillingResponseCode.OK), mutableListOf(queried))
runCurrent()
val gateView = gate.await()
gateView.single().isAutoRenewing shouldBe true
}
@Test fun `querySubscriptions excludes iap overlay entries but keeps untyped ones`() = runTest2 {
val iapEvent = purchase(1_000, token = "iap-event")
val unknownEvent = purchase(2_000, token = "unknown", products = listOf("some.unknown.product"))
val connection = BillingConnection(
client = clientReturning(result(BillingResponseCode.OK) to emptyList()),
)
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(iapEvent))
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(unknownEvent))
val gateView = connection.querySubscriptions()
// An IAP can't be the blocking subscription; an unknown product might be, so it stays in
// on the safe side.
gateView.map { it.purchaseToken } shouldBe listOf("unknown")
// Excluded from the gate view only — the reducer still owns both entries.
connection.purchases.first().map { it.purchaseToken } shouldContainExactly listOf("unknown", "iap-event")
}
@Test fun `querySubscriptions filters pending subscription results`() = runTest2 {
val pendingSub = mockk<Purchase>().apply {
every { purchaseState } returns PurchaseState.PENDING
every { purchaseTime } returns 1_000L
every { purchaseToken } returns "pending-sub"
every { this@apply.products } returns listOf(OurSku.Sub.PRO_UPGRADE.id)
}
val connection = BillingConnection(
client = clientReturning(result(BillingResponseCode.OK) to listOf(pendingSub)),
)
// A PENDING subscription is not an active one — it must neither block the gate nor
// surface as owned.
connection.querySubscriptions() shouldBe emptyList()
connection.purchases.first() shouldBe emptyList()
}
// endregion
// region acknowledgement
@Test fun `a late ack callback after the caller gave up is ignored`() = runTest2 {
var listener: AcknowledgePurchaseResponseListener? = null
val client = mockk<BillingClient>().apply {
every { acknowledgePurchase(any<AcknowledgePurchaseParams>(), any()) } answers {
listener = secondArg()
}
}
val connection = BillingConnection(client = client)
val ack = async(start = CoroutineStart.UNDISPATCHED) {
withTimeoutOrNull(1_000) { connection.acknowledgePurchase(purchase(1_000)) }
}
runCurrent()
listener.shouldNotBeNull()
advanceTimeBy(1_001) // the ack path's per-attempt timeout fires while Play is still silent
runCurrent()
ack.await() shouldBe null
// Play answers late, on its own thread: resuming the abandoned continuation must be a no-op,
// not an IllegalStateException — the bounded ack attempts depend on that contract.
listener!!.onAcknowledgePurchaseResponse(result(BillingResponseCode.OK))
runCurrent()
}
// endregion
}
@@ -1,373 +0,0 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResult
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.queryPurchasesAsync
import eu.darken.capod.common.upgrade.core.CapodSku
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
class BillingClientConnectionTest : BaseTest() {
@AfterEach
fun teardown() {
unmockkStatic("com.android.billingclient.api.BillingClientKotlinKt")
}
private fun mockPurchase(
productId: String = CapodSku.Iap.PRO_UPGRADE.id,
purchaseTime: Long = 1_000,
state: Int = Purchase.PurchaseState.PURCHASED,
token: String = "token-$productId-$purchaseTime",
): Purchase = mockk {
every { products } returns listOf(productId)
every { this@mockk.purchaseTime } returns purchaseTime
every { purchaseState } returns state
every { purchaseToken } returns token
}
private class Harness(
val purchasesGlobal: MutableStateFlow<Collection<Purchase>> = MutableStateFlow(emptySet()),
var generation: Long = 0L,
) {
val client = mockk<BillingClient>(relaxed = true)
val freshObservations = MutableSharedFlow<FreshPurchases>(replay = 1, extraBufferCapacity = 16)
val freshFailures = MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 8)
val connection = BillingClientConnection(
client = client,
purchasesGlobal = purchasesGlobal,
freshObservations = freshObservations,
freshFailuresGlobal = freshFailures,
purchaseFailuresGlobal = MutableSharedFlow(),
listenerGeneration = { generation },
)
init {
mockkStatic("com.android.billingclient.api.BillingClientKotlinKt")
}
fun okResult(purchases: List<Purchase>) = PurchasesResult(
BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK).build(),
purchases.toList(),
)
fun errorResult() = PurchasesResult(
BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.ERROR).build(),
emptyList(),
)
}
@Test
fun `pending purchases are filtered out of the push-based purchases flow`() = runTest2 {
// A PENDING purchase (e.g. a slow cash/deferred payment) must never reach the entitlement
// layer — only PURCHASED grants Pro. This guards the #628 fix at the connection boundary.
val pending = mockPurchase(purchaseTime = 2_000, state = Purchase.PurchaseState.PENDING)
val purchased = mockPurchase(purchaseTime = 1_000, state = Purchase.PurchaseState.PURCHASED)
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(pending, purchased)))
harness.connection.purchases.first() shouldBe listOf(purchased)
}
@Test
fun `refresh with both queries OK is a full snapshot`() = runTest2 {
val harness = Harness()
val owned = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returnsMany listOf(
harness.okResult(listOf(owned)),
harness.okResult(emptyList()),
)
val fresh = harness.connection.refreshPurchases()
fresh.purchases shouldBe listOf(owned)
fresh.isFullSnapshot shouldBe true
harness.freshObservations.first() shouldBe fresh
}
@Test
fun `refresh with a partial failure is presence-only`() = runTest2 {
// One type failed — the surviving type's authoritative purchase suppresses the error, but
// absence was NOT proven, so this refresh must not claim full-snapshot provenance (it
// could otherwise start a bogus unconfirmed episode for the failed type's entitlement).
val harness = Harness()
val ownedSub = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returnsMany listOf(
harness.errorResult(),
harness.okResult(listOf(ownedSub)),
)
val fresh = harness.connection.refreshPurchases()
fresh.purchases shouldBe listOf(ownedSub)
fresh.isFullSnapshot shouldBe false
}
@Test
fun `a purchase event racing the refresh downgrades the snapshot`() = runTest2 {
// The listener bumped its generation while the queries were in flight: the purchase it
// carries may be missing from our (older) query results, so an empty combined result must
// not claim to prove absence.
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } coAnswers {
harness.okResult(emptyList())
} coAndThen {
harness.generation += 1
harness.okResult(emptyList())
}
val fresh = harness.connection.refreshPurchases()
fresh.purchases shouldBe emptyList()
fresh.isFullSnapshot shouldBe false
}
@Test
fun `an inconclusive refresh throws and reports a fresh failure`() = runTest2 {
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returnsMany listOf(
harness.errorResult(),
harness.okResult(emptyList()),
)
shouldThrow<BillingResultException> {
harness.connection.refreshPurchases()
}
// The failure event is what starts the unconfirmed-episode clock during outages.
harness.freshFailures.first() shouldBe Unit
}
@Test
fun `querySubscriptions commits fresh state and supersedes stale listener records`() = runTest2 {
// The stale in-session record (isAutoRenewing=true from the purchase moment) and the
// fresh query record share a token — after the query, only the fresh one may survive,
// or ownership mapping would read "still renewing" forever.
val stale = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "shared-token")
val fresh = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "shared-token")
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(stale)))
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.okResult(listOf(fresh))
val result = harness.connection.querySubscriptions()
result shouldContainExactly listOf(fresh)
// Stale record superseded: the combined purchases view only contains the fresh record.
harness.connection.purchases.first() shouldContainExactly listOf(fresh)
// Presence-only provenance: a SUBS query proves nothing about the IAP.
harness.freshObservations.first().isFullSnapshot shouldBe false
}
@Test
fun `a subscription purchased while the query runs is still seen by the gate`() = runTest2 {
// The listener publishes a brand-new sub AND bumps the generation mid-query: the (older,
// empty) query result must neither prune nor hide it — over-blocking the switch is safe,
// missing a renewing sub is not.
val racing = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "race-token")
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } coAnswers {
harness.purchasesGlobal.value = listOf(racing)
harness.generation += 1
harness.okResult(emptyList())
}
val result = harness.connection.querySubscriptions()
result shouldContainExactly listOf(racing)
harness.connection.purchases.first() shouldContainExactly listOf(racing)
}
@Test
fun `a conclusive empty SUBS query prunes stale listener sub records`() = runTest2 {
// No purchase event raced the query, and the query proved absence for subscriptions —
// the stale in-session record (e.g. from a refunded purchase) must not resurrect Pro or
// block the switch until process restart.
val stale = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "stale-token")
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(stale)))
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.okResult(emptyList())
val result = harness.connection.querySubscriptions()
result shouldBe emptyList()
harness.connection.purchases.first() shouldBe emptyList()
}
@Test
fun `a conclusive empty full refresh prunes stale listener records`() = runTest2 {
val stale = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, token = "stale-token")
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(stale)))
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.okResult(emptyList())
val fresh = harness.connection.refreshPurchases()
fresh.isFullSnapshot shouldBe true
harness.connection.purchases.first() shouldBe emptyList()
}
@Test
fun `a raced refresh keeps newer listener records`() = runTest2 {
// Generation changed mid-refresh: the listener's record is newer than the query result,
// so nothing may be pruned and the snapshot must not claim to prove absence.
val newer = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "new-token")
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } coAnswers {
harness.purchasesGlobal.value = listOf(newer)
harness.generation += 1
harness.okResult(emptyList())
}
val fresh = harness.connection.refreshPurchases()
fresh.isFullSnapshot shouldBe false
harness.connection.purchases.first() shouldContainExactly listOf(newer)
}
@Test
fun `querySubscriptions failure propagates and reports a fresh failure`() = runTest2 {
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns
harness.errorResult()
shouldThrow<BillingResultException> {
harness.connection.querySubscriptions()
}
harness.freshFailures.first() shouldBe Unit
}
@Test
fun `combines both product types, newest first`() {
val older = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, purchaseTime = 1_000)
val newer = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, purchaseTime = 2_000)
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(older)),
subs = Result.success(listOf(newer)),
) shouldBe listOf(newer, older)
}
@Test
fun `a single product-type failure does not mask a pro purchase found by the other`() {
val owned = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(owned)),
subs = Result.failure(RuntimeException("SUBS query failed")),
) shouldBe listOf(owned)
val ownedSub = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
BillingClientConnection.combinePurchaseResults(
iaps = Result.failure(RuntimeException("IAP query failed")),
subs = Result.success(listOf(ownedSub)),
) shouldBe listOf(ownedSub)
}
@Test
fun `an unknown purchase does not suppress the other product-type's failure`() {
// An unknown/legacy product is discarded by BillingData later — it must not hide that the
// other product type couldn't be verified, or a restore would wrongly report "not owned".
val unknown = mockPurchase("some.legacy.product")
shouldThrow<RuntimeException> {
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(unknown)),
subs = Result.failure(RuntimeException("SUBS query failed")),
)
}
}
@Test
fun `unknown purchases are returned as-is when both queries succeed`() {
val unknown = mockPurchase("some.legacy.product")
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(unknown)),
subs = Result.success(emptyList()),
) shouldBe listOf(unknown)
}
@Test
fun `both product types empty returns empty`() {
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(emptyList()),
subs = Result.success(emptyList()),
) shouldBe emptyList()
}
@Test
fun `nothing found but a query failed rethrows the error`() {
shouldThrow<RuntimeException> {
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(emptyList()),
subs = Result.failure(RuntimeException("SUBS query failed")),
)
}
shouldThrow<RuntimeException> {
BillingClientConnection.combinePurchaseResults(
iaps = Result.failure(RuntimeException("IAP query failed")),
subs = Result.success(emptyList()),
)
}
}
// --- Token-based dedup of the merged purchases view (P4) ---
@Test
fun `a same-token listener overlay overwrites the query-cache record`() = runTest2 {
// The query cache holds a snapshot of a purchase; the listener then pushes a fresher copy
// (differing ack-state) under the same token. Dedup by token must keep exactly one entry,
// and the listener overlay — left in place by reconciliation — wins.
val cached = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, token = "shared")
val overlay = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, token = "shared")
val harness = Harness()
coEvery { harness.client.queryPurchasesAsync(any<QueryPurchasesParams>()) } returnsMany listOf(
harness.okResult(listOf(cached)),
harness.okResult(emptyList()),
)
harness.connection.refreshPurchases()
// A listener push arriving after the refresh — same token, fresher instance.
harness.purchasesGlobal.value = listOf(overlay)
harness.connection.purchases.first() shouldContainExactly listOf(overlay)
}
@Test
fun `distinct-token purchases all survive and stay ordered by purchase time`() = runTest2 {
val older = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, token = "a", purchaseTime = 1_000)
val newer = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "b", purchaseTime = 2_000)
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(older, newer)))
harness.connection.purchases.first() shouldContainExactly listOf(newer, older)
}
@Test
fun `a purchased record with a blank token is discarded`() = runTest2 {
// Play supplies a non-empty token for PURCHASED purchases; a blank one is malformed and
// must not collapse every such record under the "" key.
val valid = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, token = "valid", purchaseTime = 2_000)
val blank = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, token = "", purchaseTime = 1_000)
val harness = Harness(purchasesGlobal = MutableStateFlow(listOf(valid, blank)))
harness.connection.purchases.first() shouldContainExactly listOf(valid)
}
}
@@ -1,451 +0,0 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.AppForegroundState
import eu.darken.capod.common.upgrade.core.client.BillingClientConnection
import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider
import eu.darken.capod.common.upgrade.core.client.BillingException
import eu.darken.capod.common.upgrade.core.client.BillingResultException
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.client.ItemAlreadyOwnedBillingException
import eu.darken.capod.common.upgrade.core.client.UserCanceledBillingException
import eu.darken.capod.common.upgrade.core.client.FreshPurchases
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo.Companion.tryMapUserFriendly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.runTest2
import java.time.Duration
class BillingDataRepoTest : BaseTest() {
private fun mockBillingResult(responseCode: Int): BillingResult = mockk {
every { this@mockk.responseCode } returns responseCode
every { debugMessage } returns "mock"
}
@Test
fun `temporary unavailable maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `service disconnected maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `service timeout maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_TIMEOUT)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `permanent unavailable maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `network error maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.NETWORK_ERROR)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
@Suppress("DEPRECATION")
fun `transient launch failures are not bug-reported, actionable ones are`() {
with(BillingDataRepo.IGNORED_LAUNCH_CODES) {
// Expected user/environmental situations — the user already sees proper UI for these.
contains(BillingClient.BillingResponseCode.USER_CANCELED) shouldBe true
contains(BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) shouldBe true
contains(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) shouldBe true
contains(BillingClient.BillingResponseCode.ERROR) shouldBe true
contains(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) shouldBe true
contains(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED) shouldBe true
contains(BillingClient.BillingResponseCode.SERVICE_TIMEOUT) shouldBe true
contains(BillingClient.BillingResponseCode.NETWORK_ERROR) shouldBe true
contains(BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED) shouldBe true
// Actionable defects must keep reporting.
contains(BillingClient.BillingResponseCode.DEVELOPER_ERROR) shouldBe false
contains(BillingClient.BillingResponseCode.ITEM_UNAVAILABLE) shouldBe false
}
}
@Test
fun `connection retry uses capped backoff and retries early on foreground entry`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
var attempts = 0
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flow {
attempts++
throw BillingException("still broken")
}
}
val foreground = MutableStateFlow(false)
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns foreground
}
try {
BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.runCurrent()
attempts shouldBe 1
// First backoff is 60s — not a second sooner.
testScope.testScheduler.advanceTimeBy(59_000)
testScope.testScheduler.runCurrent()
attempts shouldBe 1
testScope.testScheduler.advanceTimeBy(2_000)
testScope.testScheduler.runCurrent()
attempts shouldBe 2
// Second backoff would be 120s — a foreground entry short-circuits it, so a user
// returning from e.g. Google sign-in doesn't wait out the full backoff. A foreground
// entry drives two independent early-retry paths (the retry loop's own foreground
// branch, plus the init foreground-refresh calling refresh() which emits a kick), so
// the exact count is interleaving-dependent — either can produce attempt 3 or 4.
testScope.testScheduler.advanceTimeBy(5_000)
foreground.value = true
testScope.testScheduler.runCurrent()
(attempts in 3..4) shouldBe true
// ...and then it settles: with no further action/lifecycle signal and time still well
// under the next backoff, the double-kick must not compound into a busy-loop.
val settled = attempts
testScope.testScheduler.advanceTimeBy(5_000)
testScope.testScheduler.runCurrent()
attempts shouldBe settled
} finally {
// The repo pipelines and retry loop are infinite — a leaked scope after a failed
// assertion would keep them alive for the rest of the JVM.
testScope.cancel()
}
}
@Test
fun `explicit billing operations kick a waiting connection retry`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
var attempts = 0
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flow {
attempts++
throw BillingException("still broken")
}
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
val repo = BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.runCurrent()
attempts shouldBe 1
// A restore-style refresh() while the retry is waiting out its 60s backoff kicks it
// immediately — the user shouldn't wait out the timer after fixing Play themselves.
testScope.testScheduler.advanceTimeBy(5_000)
val refreshJob = testScope.launch { runCatching { repo.refresh() } }
testScope.testScheduler.runCurrent()
attempts shouldBe 2
refreshJob.cancel()
} finally {
testScope.cancel()
}
}
@Test
fun `user canceled maps to UserCanceledBillingException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.USER_CANCELED)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<UserCanceledBillingException>()
}
@Test
fun `item already owned maps to ItemAlreadyOwnedBillingException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<ItemAlreadyOwnedBillingException>()
}
@Test
fun `other billing result passes through unchanged`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.DEVELOPER_ERROR)
val original = BillingResultException(result)
val mapped = original.tryMapUserFriendly()
mapped shouldBe original
}
@Test
fun `non-billing exception passes through unchanged`() {
val original = IllegalStateException("something else")
val mapped = original.tryMapUserFriendly()
mapped shouldBe original
}
@Test
fun `generic BillingException passes through unchanged`() {
val original = BillingException("generic billing error")
val mapped = original.tryMapUserFriendly()
mapped shouldBe original
}
private fun mockPurchase(
state: Int,
acknowledged: Boolean,
token: String = "token-$state-$acknowledged",
): Purchase = mockk {
every { purchaseState } returns state
every { isAcknowledged } returns acknowledged
every { purchaseToken } returns token
}
@Test
fun `ack pipeline acknowledges only unacknowledged PURCHASED purchases`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// A PENDING purchase must not be acknowledged (acking it fails and spins the retry loop);
// an already-acknowledged one must not be re-acked; only a settled, unacked one is acked.
val pending = mockPurchase(Purchase.PurchaseState.PENDING, acknowledged = false)
val purchasedUnacked = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = false)
val purchasedAcked = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = true)
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns flowOf(listOf(pending, purchasedUnacked, purchasedAcked))
coEvery { acknowledgePurchase(any()) } returns Unit
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.runCurrent()
coVerify(exactly = 1) { clientConnection.acknowledgePurchase(purchasedUnacked) }
coVerify(exactly = 0) { clientConnection.acknowledgePurchase(pending) }
coVerify(exactly = 0) { clientConnection.acknowledgePurchase(purchasedAcked) }
} finally {
testScope.cancel()
}
}
private class ForegroundRefreshHarness(testScope: TestScope) {
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow()
every { freshFailures } returns emptyFlow()
coEvery { refreshPurchases() } returns FreshPurchases(emptyList(), isFullSnapshot = true)
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(this@ForegroundRefreshHarness.clientConnection)
}
val foreground = MutableStateFlow(false)
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns foreground
}
val timeSource = TestTimeSource()
val repo = BillingDataRepo(provider, testScope, foregroundState, timeSource)
}
@Test
fun `coming to the foreground triggers a purchase refresh, throttled`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val harness = ForegroundRefreshHarness(testScope)
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 1) { harness.clientConnection.refreshPurchases() }
// Background/foreground again within the throttle window -> no additional query.
harness.foreground.value = false
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 1) { harness.clientConnection.refreshPurchases() }
testScope.cancel()
}
@Test
fun `foreground refresh runs again once the throttle window has passed`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val harness = ForegroundRefreshHarness(testScope)
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 1) { harness.clientConnection.refreshPurchases() }
harness.timeSource.advanceBy(Duration.ofMinutes(61))
harness.foreground.value = false
harness.foreground.value = true
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 2) { harness.clientConnection.refreshPurchases() }
testScope.cancel()
}
@Test
fun `staying in the background never triggers a refresh`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val harness = ForegroundRefreshHarness(testScope)
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 0) { harness.clientConnection.refreshPurchases() }
testScope.cancel()
}
@Test
fun `a successfully acked purchase is not re-acked when a stale snapshot re-emits`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// The immutable Purchase snapshot keeps claiming isAcknowledged=false until a fresh query
// supersedes it — re-emissions of that stale view must not re-ack the same purchase.
val stale = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = false)
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns flowOf(listOf(stale), listOf(stale), listOf(stale))
coEvery { acknowledgePurchase(any()) } returns Unit
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.runCurrent()
coVerify(exactly = 1) { clientConnection.acknowledgePurchase(stale) }
} finally {
testScope.cancel()
}
}
@Test
fun `a FAILED ack is not suppressed and still retries`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Only a SUCCESSFUL ack may enter the suppression set — a failure must stay retryable.
val unacked = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = false)
var ackAttempts = 0
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns flowOf(listOf(unacked))
coEvery { acknowledgePurchase(any()) } coAnswers {
ackAttempts++
if (ackAttempts == 1) {
throw BillingResultException(mockBillingResult(BillingClient.BillingResponseCode.ERROR))
}
}
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.advanceUntilIdle()
coVerify(exactly = 2) { clientConnection.acknowledgePurchase(unacked) }
} finally {
testScope.cancel()
}
}
@Test
fun `a foreground refresh timeout is reported as a refresh failure`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// A hung refresh is cancelled by the foreground pipeline's timeout BEFORE the query's own
// failure path can signal anything — without this local report, a sustained outage would
// never start the unconfirmed-episode clock.
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow()
every { freshFailures } returns emptyFlow()
coEvery { refreshPurchases() } coAnswers { awaitCancellation() }
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foreground = MutableStateFlow(false)
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns foreground
}
try {
val repo = BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
val failures = mutableListOf<Unit>()
val collectJob = testScope.launch { repo.refreshFailures.toList(failures) }
foreground.value = true
testScope.testScheduler.advanceTimeBy(31_000)
testScope.testScheduler.runCurrent()
failures.size shouldBe 1
collectJob.cancel()
} finally {
testScope.cancel()
}
}
@Test
fun `querySubscriptions delegates to the connection and maps errors`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow()
every { freshFailures } returns emptyFlow()
coEvery { querySubscriptions() } throws BillingResultException(
mockBillingResult(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
)
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns MutableStateFlow(false)
}
try {
val repo = BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
// The strict SUBS gate propagates errors, mapped to the user-friendly type so the
// caller's error dialog can tell "Play unavailable" apart from other failures.
shouldThrow<GplayServiceUnavailableException> {
repo.querySubscriptions()
}
} finally {
testScope.cancel()
}
}
}
@@ -1,70 +0,0 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.CapodSku
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BillingDataTest : BaseTest() {
private fun mockPurchase(vararg productIds: String): Purchase = mockk {
every { products } returns productIds.toList()
}
@Test
fun `empty purchases yields empty purchasedSkus`() {
val data = BillingData(purchases = emptyList())
data.purchasedSkus.shouldBeEmpty()
}
@Test
fun `purchase with IAP product ID maps to IAP SKU`() {
val purchase = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.size shouldBe 1
data.purchasedSkus.first().sku shouldBe CapodSku.Iap.PRO_UPGRADE
data.purchasedSkus.first().purchase shouldBe purchase
}
@Test
fun `purchase with subscription product ID maps to Sub SKU`() {
val purchase = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.size shouldBe 1
data.purchasedSkus.first().sku shouldBe CapodSku.Sub.PRO_UPGRADE
data.purchasedSkus.first().purchase shouldBe purchase
}
@Test
fun `purchase with unknown product ID is filtered out`() {
val purchase = mockPurchase("com.unknown.product")
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.shouldBeEmpty()
}
@Test
fun `purchase with mixed products returns only matching SKU`() {
val purchase = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, "com.unknown.product")
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.size shouldBe 1
data.purchasedSkus.first().sku shouldBe CapodSku.Sub.PRO_UPGRADE
}
@Test
fun `multiple purchases with different pro SKUs`() {
val iapPurchase = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
val subPurchase = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
val data = BillingData(purchases = listOf(iapPurchase, subPurchase))
data.purchasedSkus.size shouldBe 2
data.purchasedSkus.map { it.sku }.toSet() shouldBe setOf(CapodSku.Iap.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE)
}
}
@@ -0,0 +1,92 @@
package eu.darken.capod.common.upgrade.ui
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.billing.BillingData
import io.mockk.every
import io.mockk.mockk
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class GplayUpgradeOwnershipTest : BaseTest() {
private fun mockPurchase(skuId: String, autoRenewing: Boolean = false): Purchase = mockk<Purchase>().apply {
every { products } returns listOf(skuId)
every { isAutoRenewing } returns autoRenewing
every { purchaseTime } returns 1234L
}
private fun info(vararg purchases: Purchase) = UpgradeRepoGplay.Info(
false,
BillingData(purchases = purchases.toList()),
null,
)
@Test
fun `no purchases means no ownership`() {
val ownership = info().toOwnership()
ownership.hasIap.shouldBeFalse()
ownership.subscription.shouldBeNull()
ownership.ownsAnything.shouldBeFalse()
}
@Test
fun `one-time purchase maps to iap ownership`() {
val ownership = info(mockPurchase("eu.darken.capod.iap.upgrade.pro")).toOwnership()
ownership.hasIap.shouldBeTrue()
ownership.subscription.shouldBeNull()
ownership.ownsAnything.shouldBeTrue()
}
@Test
fun `renewing subscription maps to renewing ownership`() {
val ownership = info(mockPurchase("upgrade.pro", autoRenewing = true)).toOwnership()
ownership.hasIap.shouldBeFalse()
ownership.subscription.shouldNotBeNull().isAutoRenewing.shouldBeTrue()
}
@Test
fun `cancelled but still running subscription maps to non-renewing ownership`() {
val ownership = info(mockPurchase("upgrade.pro", autoRenewing = false)).toOwnership()
ownership.subscription.shouldNotBeNull().isAutoRenewing.shouldBeFalse()
}
@Test
fun `owning both products is represented as both`() {
val ownership = info(
mockPurchase("eu.darken.capod.iap.upgrade.pro"),
mockPurchase("upgrade.pro", autoRenewing = false),
).toOwnership()
ownership.hasIap.shouldBeTrue()
ownership.subscription.shouldNotBeNull().isAutoRenewing.shouldBeFalse()
}
@Test
fun `multiple subscription records stay renewing if any record still renews`() {
// A retained purchase event can coexist with fresher query-cache data for the same sub;
// display must err on the renewing side — the purchase gate re-verifies freshly anyway.
val ownership = info(
mockPurchase("upgrade.pro", autoRenewing = false),
mockPurchase("upgrade.pro", autoRenewing = true),
).toOwnership()
ownership.subscription.shouldNotBeNull().isAutoRenewing.shouldBeTrue()
}
@Test
fun `unknown products do not create ownership`() {
val ownership = info(mockPurchase("some.unknown.sku")).toOwnership()
ownership.ownsAnything shouldBe false
}
}
@@ -0,0 +1,627 @@
package eu.darken.capod.common.upgrade.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.core.app.ApplicationProvider
import com.android.billingclient.api.ProductDetails
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
// capod's hero bodies name the app inline instead of taking a format argument.
private fun appNameWithPostfixedHeroBody(bodyRes: Int): String = context.getString(bodyRes)
// "CAPod Pro" — the composed flavor title the screen renders for owners and grace users.
private val appNameWithPostfix: String
get() = context.getString(R.string.app_name_pro)
@Test
fun `loading state shows progress and hides actions`() {
composeRule.setUpgradeContent {
UpgradeScreen(uiState = GplayUpgradeUiState.Loading)
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.LOADING).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.ACTIONS).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_preamble)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_benefits_title)).assertCountEquals(1)
}
@Test
fun `loaded state shows trial before iap and hides loading`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.TRIAL,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
),
)
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.LOADING).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.ACTIONS).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_trial_action)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_iap_action)).assertCountEquals(1)
// Compact rows: the price shares the title line, per-offer captions carry the terms (the
// standalone "Options" card is gone), and there is no badge.
composeRule.onAllNodesWithText(
"${context.getString(R.string.upgrade_screen_subscription_offer_title)} · $12.99"
).assertCountEquals(1)
composeRule.onAllNodesWithText(
"${context.getString(R.string.upgrade_screen_iap_offer_title)} · $24.99"
).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_offers_title)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_offers_body)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_offers_or)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_iap_offer_body)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_restore_purchase_action)).assertCountEquals(1)
val subscriptionButtonTop = composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).getUnclippedBoundsInRoot().top
val iapButtonTop = composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).getUnclippedBoundsInRoot().top
check(subscriptionButtonTop < iapButtonTop) {
"Expected subscription action to appear above IAP action, but got top=$subscriptionButtonTop and top=$iapButtonTop"
}
// Terms read ABOVE their buttons (description-then-action, like the restore section); the
// header anchors the top and the parity footnote sits below both offers.
val subscriptionCaptionTop = composeRule
.onNodeWithText(context.getString(R.string.upgrade_screen_subscription_offer_body))
.getUnclippedBoundsInRoot().top
val headerTop = composeRule
.onNodeWithText(context.getString(R.string.upgrade_screen_offers_title))
.getUnclippedBoundsInRoot().top
val footerTop = composeRule
.onNodeWithText(context.getString(R.string.upgrade_screen_offers_body))
.getUnclippedBoundsInRoot().top
check(subscriptionCaptionTop < subscriptionButtonTop) {
"Expected subscription terms above their button, got terms=$subscriptionCaptionTop button=$subscriptionButtonTop"
}
check(headerTop < subscriptionButtonTop) {
"Expected the offers header above the offers, got header=$headerTop subButton=$subscriptionButtonTop"
}
check(footerTop > iapButtonTop) {
"Expected the parity footnote below both offers, got footer=$footerTop iapButton=$iapButtonTop"
}
}
@Test
fun `loaded state keeps unavailable actions visible but disabled`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.UNAVAILABLE,
subscriptionEnabled = false,
subscriptionPrice = null,
iapEnabled = false,
iapPrice = null,
),
)
}
composeRule.onNodeWithText(context.getString(R.string.upgrade_screen_subscription_action)).assertIsNotEnabled()
composeRule.onNodeWithText(context.getString(R.string.upgrade_screen_iap_action)).assertIsNotEnabled()
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_restore_purchase_action)).assertCountEquals(1)
// Without prices the rows fall back to bare titles (exact match proves no dangling "·"),
// and the unavailable subscription promises no trial.
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_title)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_iap_offer_title)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body_no_trial)).assertCountEquals(1)
}
@Test
fun `unavailable state hides loading and purchase actions while keeping static content`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Unavailable(
error = RuntimeException("Google Play services unavailable"),
),
)
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.LOADING).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_IAP).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_RESTORE).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_UNAVAILABLE).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_offers_unavailable_message)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_benefits_title)).assertCountEquals(1)
}
@Test
fun `returning buyer sees the restore banner and can trigger restore`() {
var restoreClicks = 0
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
wasPreviouslyPro = true,
),
onRestore = { restoreClicks++ },
)
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_RESTORE_BANNER).assertCountEquals(1)
// The targeted section is the ONLY restore affordance — no second generic one below.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_RESTORE).assertCountEquals(0)
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RESTORE_BANNER_ACTION).performClick()
composeRule.runOnIdle { check(restoreClicks == 1) { "expected 1 restore click, got $restoreClicks" } }
}
@Test
fun `banner is hidden without a prior purchase on this device`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
wasPreviouslyPro = false,
),
)
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_RESTORE_BANNER).assertCountEquals(0)
}
@Test
fun `the returning-buyer restore is disabled while a restore is running`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
wasPreviouslyPro = true,
busy = BusyOp.RESTORE,
),
)
}
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RESTORE_BANNER_ACTION).assertIsNotEnabled()
}
@Test
fun `plain acquisition gets a described restore section below the offers`() {
var restoreClicks = 0
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
),
onRestore = { restoreClicks++ },
)
}
// The offers card holds only offers — restore lives in its own described section. No
// contact-support affordance here: support is only suggested after a restore came up
// empty (the failed-restore dialog).
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_restore_body)).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_RESTORE).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_contact_support_action))
.assertCountEquals(0)
// STANDARD subscription (no trial offer): the row must not promise a trial.
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body))
.assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body_no_trial))
.assertCountEquals(1)
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RESTORE).performScrollTo().performClick()
composeRule.runOnIdle { check(restoreClicks == 1) { "expected 1 restore click, got $restoreClicks" } }
}
@Test
fun `a running entitlement action pauses the buy actions too`() {
// toLoadedState computes the enabled flags: any busy op (a restore -- manual or the
// invisible already-owned recovery -- or a purchase launch) must gate them even when offers
// are available; a buy tap would just race the other operation into ITEM_ALREADY_OWNED.
val iapOffer = mockk<ProductDetails.OneTimePurchaseOfferDetails>(relaxed = true)
val iapDetails = mockk<ProductDetails>(relaxed = true).apply {
every { oneTimePurchaseOfferDetails } returns iapOffer
}
val subOffer = mockk<ProductDetails.SubscriptionOfferDetails>(relaxed = true).apply {
every { basePlanId } returns OurSku.Sub.PRO_UPGRADE.BASE_OFFER.basePlanId
every { offerId } returns null
}
val subDetails = mockk<ProductDetails>(relaxed = true).apply {
every { subscriptionOfferDetails } returns listOf(subOffer)
}
val loaded = toLoadedState(
iap = SkuDetails(OurSku.Iap.PRO_UPGRADE, iapDetails),
sub = SkuDetails(OurSku.Sub.PRO_UPGRADE, subDetails),
ownership = Ownership(),
busy = BusyOp.RESTORE,
)
check(!loaded.iapEnabled) { "IAP buy must be disabled during a restore" }
check(!loaded.subscriptionEnabled) { "Subscription buy must be disabled during a restore" }
// Same offers without a running restore: both buys are available.
val idle = toLoadedState(
iap = SkuDetails(OurSku.Iap.PRO_UPGRADE, iapDetails),
sub = SkuDetails(OurSku.Sub.PRO_UPGRADE, subDetails),
ownership = Ownership(),
busy = null,
)
check(idle.iapEnabled) { "IAP buy should be enabled when idle" }
check(idle.subscriptionEnabled) { "Subscription buy should be enabled when idle" }
}
@Test
fun `unavailable state offers a retry that fires the callback`() {
var retryClicks = 0
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Unavailable(
error = RuntimeException("Google Play services unavailable"),
),
onRetry = { retryClicks++ },
)
}
// The unavailable card sits below the fold of the scrollable screen: an offscreen click
// would silently miss the button.
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RETRY).performScrollTo().performClick()
composeRule.runOnIdle { check(retryClicks == 1) { "expected 1 retry click, got $retryClicks" } }
}
@Test
fun `offer copy promises the trial only when Play returned the trial offer`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.TRIAL,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
),
)
}
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body_no_trial))
.assertCountEquals(0)
}
private fun ownedState(ownership: Ownership, busy: BusyOp? = null) =
GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.UNAVAILABLE,
subscriptionEnabled = false,
subscriptionPrice = null,
iapEnabled = !ownership.hasIap,
iapPrice = "$24.99",
ownership = ownership,
busy = busy,
)
@Test
fun `renewing subscription owner sees a locked one-time offer and management`() {
var iapClicks = 0
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = ownedState(Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true))),
onIap = { iapClicks++ },
)
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_MANAGE_SUB).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_owned_sub_renewing_body)).assertCountEquals(1)
composeRule.onAllNodesWithText(appNameWithPostfix).assertCountEquals(1)
// The congrats hero names the variant.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_OWNED_HERO).assertCountEquals(1)
composeRule.onAllNodesWithText(appNameWithPostfixedHeroBody(R.string.upgrade_screen_owned_hero_sub_body))
.assertCountEquals(1)
// The switch path is a visible LOCKED offer: present, disabled, with the unlock condition.
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_owned_iap_locked_note)).assertCountEquals(1)
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).assertIsNotEnabled()
composeRule.runOnIdle { check(iapClicks == 0) { "locked offer must not be clickable" } }
// No acquisition upsell copy anywhere on the ownership screen.
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_preamble)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_benefits_title)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_offers_title)).assertCountEquals(0)
// Restore stays available in every ownership state: it reconciles entitlements, not upsell.
// Framed as a status re-check; support is only offered by the failed-restore dialog.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_RESTORE).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_restore_status_title)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_contact_support_action))
.assertCountEquals(0)
}
@Test
fun `non-renewing subscription owner can buy the one-time upgrade`() {
var iapClicks = 0
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = ownedState(Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false))),
onIap = { iapClicks++ },
)
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_MANAGE_SUB).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_owned_sub_not_renewing_body)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_owned_iap_purchase_note)).assertCountEquals(1)
// The offer is unlocked — the locked-state note must be gone.
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_owned_iap_locked_note)).assertCountEquals(0)
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).performScrollTo().performClick()
composeRule.runOnIdle { check(iapClicks == 1) { "expected 1 iap click, got $iapClicks" } }
}
@Test
fun `one-time owner sees owned status without purchase options`() {
composeRule.setUpgradeContent {
UpgradeScreen(uiState = ownedState(Ownership(hasIap = true)))
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_OWNED_IAP).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_IAP).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_MANAGE_SUB).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_owned_iap_body)).assertCountEquals(1)
// The hero names the permanent purchase as the unlock, never the subscription variant.
composeRule.onAllNodesWithText(appNameWithPostfixedHeroBody(R.string.upgrade_screen_owned_hero_iap_body))
.assertCountEquals(1)
composeRule.onAllNodesWithText(appNameWithPostfixedHeroBody(R.string.upgrade_screen_owned_hero_sub_body))
.assertCountEquals(0)
}
@Test
fun `owning both with a renewing subscription shows the renewal warning`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = ownedState(
Ownership(hasIap = true, subscription = SubscriptionOwnership(isAutoRenewing = true)),
),
)
}
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_owned_both_renewing_warning)).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_MANAGE_SUB).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_IAP).assertCountEquals(0)
}
private fun graceState(showDiagnostics: Boolean) = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
grace = GraceHint(showDiagnostics = showDiagnostics),
)
@Test
fun `quiet grace stage confirms pro without diagnostics or offers`() {
composeRule.setUpgradeContent {
UpgradeScreen(uiState = graceState(showDiagnostics = false))
}
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_grace_title)).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_grace_body_short)).assertCountEquals(1)
// "Confirming…" is backed by motion during the quiet stage; the mascot stays cheerful.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE_SPINNER).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE_RESTORE).assertCountEquals(0)
// The grace card owns restore via its two-stage disclosure — the generic restore section
// must not undercut the calm quiet stage with its own restore CTA.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_RESTORE).assertCountEquals(0)
// Grace users are still Pro: neutral status title, not the acquisition pitch title.
composeRule.onAllNodesWithText(appNameWithPostfix).assertCountEquals(1)
// A young episode is treated as a blip: calm status only — no offers, no sales pitch.
// The offers return with the aged (diagnostics) stage.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_IAP).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_preamble)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_benefits_title)).assertCountEquals(0)
}
@Test
fun `grace restore action is disabled while a restore runs`() {
composeRule.setUpgradeContent {
UpgradeScreen(uiState = graceState(showDiagnostics = true).copy(busy = BusyOp.RESTORE))
}
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_GRACE_RESTORE).assertIsNotEnabled()
}
@Test
fun `ownership buy button is paused while a restore runs`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = ownedState(Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)))
.copy(busy = BusyOp.RESTORE),
)
}
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).assertIsNotEnabled()
}
@Test
fun `aged grace stage shows diagnostics with an inline restore action`() {
var restoreClicks = 0
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = graceState(showDiagnostics = true),
onRestore = { restoreClicks++ },
)
}
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_grace_body)).assertCountEquals(1)
// The aged copy asks the user to act — no spinner contradicting the restore CTA, and the
// mascot switches to the grumpy "needs your attention" face.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE_SPINNER).assertCountEquals(0)
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_GRUMPY).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.MASCOT_HAPPY).assertCountEquals(0)
// The aged episode is treated as likely-permanent: the offers come back so an expired
// subscriber can switch without waiting out the full grace window. Still no sales pitch.
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_IAP).assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_preamble)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_benefits_title)).assertCountEquals(0)
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_GRACE_RESTORE).performScrollTo().performClick()
composeRule.runOnIdle { check(restoreClicks == 1) { "expected 1 restore click, got $restoreClicks" } }
}
@Test
fun `ownership buy button is disabled while verification is running`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = ownedState(
Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
busy = BusyOp.IAP,
),
)
}
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).assertIsNotEnabled()
}
@Test
fun `a running subscription launch spins on its own button and pauses the rest`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
busy = BusyOp.SUBSCRIPTION,
),
)
}
// The spinner belongs to the action the user started -- the IAP button must not claim it,
// and every other entitlement action is paused while Play is being talked to.
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RESTORE).assertIsNotEnabled()
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION_SPINNER).assertCountEquals(1)
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_IAP_SPINNER).assertCountEquals(0)
}
@Test
fun `offer copy drops the trial promise when only the base offer is available`() {
composeRule.setUpgradeContent {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = true,
subscriptionPrice = "$12.99",
iapEnabled = true,
iapPrice = "$24.99",
),
)
}
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body_no_trial))
.assertCountEquals(1)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_subscription_offer_body)).assertCountEquals(0)
}
@Test
fun `the inconclusive dialog neither claims a check nor escalates`() {
// The whole point of splitting this off RestoreFailed: a non-answer must not assert that
// Play was checked, must not blame the account setup, and must not push toward support.
composeRule.setUpgradeContent { RestoreInconclusiveDialog() }
composeRule.onNodeWithText(
context.getString(R.string.upgrade_screen_restore_inconclusive_message),
substring = true,
).assertExists()
composeRule.onNodeWithText(
context.getString(R.string.upgrade_screen_restore_sync_patience_hint),
substring = true,
).assertExists()
composeRule.onAllNodesWithText(
context.getString(R.string.upgrade_screen_restore_checked_message),
substring = true,
).assertCountEquals(0)
composeRule.onAllNodesWithText(
context.getString(R.string.upgrade_screen_restore_multiaccount_hint),
substring = true,
).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_contact_support_action))
.assertCountEquals(0)
}
@Test
fun `the inconclusive dialog offers a retry that fires the callback`() {
var retries = 0
composeRule.setUpgradeContent { RestoreInconclusiveDialog(onRetry = { retries++ }) }
composeRule.onNodeWithText(context.getString(R.string.general_retry_action)).performClick()
composeRule.runOnIdle { retries shouldBe 1 }
}
@Test
fun `the empty-result dialog keeps the escalation path`() {
// Counterpart to the test above: here Play really did answer, so the multi-account hint and
// contact-support action remain warranted.
var supportTaps = 0
composeRule.setUpgradeContent { RestoreFailedDialog(onContactSupport = { supportTaps++ }) }
composeRule.onNodeWithText(
context.getString(R.string.upgrade_screen_restore_checked_message),
substring = true,
).assertExists()
composeRule.onNodeWithText(
context.getString(R.string.upgrade_screen_restore_multiaccount_hint),
substring = true,
).assertExists()
composeRule.onNodeWithText(context.getString(R.string.upgrade_screen_contact_support_action)).performClick()
composeRule.runOnIdle { supportTaps shouldBe 1 }
}
}
private fun ComposeContentTestRule.setUpgradeContent(
content: @Composable () -> Unit,
) {
setContent {
PreviewWrapper {
content()
}
}
}
@@ -0,0 +1,884 @@
package eu.darken.capod.common.upgrade.ui
import android.app.Activity
import androidx.lifecycle.SavedStateHandle
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.navigation.NavEvent
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.billing.BillingData
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.billing.Sku
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.coroutine.runTest2
import java.time.Duration
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class GplayUpgradeViewModelTest : BaseTest() {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
@Test
fun `service timeout becomes unavailable state and error event instead of crashing`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
coEvery { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } coAnswers {
delay(20_000) // longer than the 15s SKU query timeout
emptyList()
}
coEvery { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } coAnswers {
delay(20_000)
emptyList()
}
val vm = buildVm(repo)
val unavailableState = async {
vm.state.first { it is GplayUpgradeUiState.Unavailable }
}
val forwardedError = async { vm.errorEvents.first() }
advanceUntilIdle()
unavailableState.await().shouldBeInstanceOf<GplayUpgradeUiState.Unavailable>()
forwardedError.await().shouldBeInstanceOf<GplayServiceUnavailableException>()
vm.state.value.shouldBeInstanceOf<GplayUpgradeUiState.Unavailable>()
coVerify(exactly = 1) { repo.querySkus(OurSku.Iap.PRO_UPGRADE) }
coVerify(exactly = 1) { repo.querySkus(OurSku.Sub.PRO_UPGRADE) }
}
@Test
fun `a slow but healthy Play store loads instead of tripping the timeout`() = runTest2(
context = testDispatcher,
) {
// The first-ever billing query after Play sign-in measured 8.5s on-device: the old 5s
// timeout turned that healthy store into a false "Play unavailable".
val repo = mockRepo()
coEvery { repo.querySkus(any()) } coAnswers {
delay(9_000)
emptyList()
}
val vm = buildVm(repo)
val loaded = async { vm.state.first { it is GplayUpgradeUiState.Loaded } }
advanceUntilIdle()
loaded.await().shouldBeInstanceOf<GplayUpgradeUiState.Loaded>()
}
@Test
fun `retry recovers the screen after a full unavailable episode`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
var calls = 0
coEvery { repo.querySkus(any()) } coAnswers {
// First generation (both product types) fails; the retried generation succeeds.
if (calls++ < 2) throw GplayServiceUnavailableException(RuntimeException("Play hiccup"))
emptyList()
}
val vm = buildVm(repo)
val unavailable = async { vm.state.first { it is GplayUpgradeUiState.Unavailable } }
advanceUntilIdle()
unavailable.await().shouldBeInstanceOf<GplayUpgradeUiState.Unavailable>()
// Without the retry, the Lazily-cached failure bricked the screen for the VM lifetime.
vm.retrySkuQuery()
val loaded = async { vm.state.first { it is GplayUpgradeUiState.Loaded } }
advanceUntilIdle()
loaded.await().shouldBeInstanceOf<GplayUpgradeUiState.Loaded>()
coVerify(exactly = 4) { repo.querySkus(any()) }
}
@Test
fun `a single failed product type keeps the screen loaded and surfaces the error once`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
val boom = IllegalStateException("IAP details broken")
coEvery { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } throws boom
coEvery { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } returns emptyList()
val vm = buildVm(repo)
val loaded = async { vm.state.first { it is GplayUpgradeUiState.Loaded } }
val forwardedError = async { vm.errorEvents.first() }
advanceUntilIdle()
// The working product type is still offered; only the failure is reported.
loaded.await().shouldBeInstanceOf<GplayUpgradeUiState.Loaded>()
forwardedError.await() shouldBe boom
}
@Test
fun `the repo's auto-restore busy state folds into the busy op`() = runTest2(
context = testDispatcher,
) {
val autoBusy = MutableStateFlow(false)
val repo = mockRepo()
every { repo.autoRestoreBusy } returns autoBusy
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val idle = async {
vm.state.first { it is GplayUpgradeUiState.Loaded } as GplayUpgradeUiState.Loaded
}
advanceUntilIdle()
idle.await().busy shouldBe null
// The invisible already-owned recovery must pause the entitlement actions like a manual
// restore does -- the user can't be allowed to race it with a buy or another restore.
autoBusy.value = true
val busy = async {
vm.state.first { it is GplayUpgradeUiState.Loaded && it.busy != null }
}
advanceUntilIdle()
(busy.await() as GplayUpgradeUiState.Loaded).busy shouldBe BusyOp.RESTORE
}
private fun mockRepo(): UpgradeRepoGplay = mockk<UpgradeRepoGplay>(relaxed = true).apply {
every { upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(false, null, null, isSettled = true))
every { wasEverPro } returns MutableStateFlow(false)
every { proUnconfirmedSince } returns MutableStateFlow(0L)
// Relaxed mocks return a no-op Flow that never emits -- the state combine would starve.
every { autoRestoreBusy } returns MutableStateFlow(false)
every { purchaseLaunchSku } returns MutableStateFlow<Sku?>(null)
}
private fun buildVm(
repo: UpgradeRepoGplay,
webpageTool: WebpageTool = mockk(relaxed = true),
): UpgradeViewModel = UpgradeViewModel(
handle = SavedStateHandle(mapOf("forced" to false)),
dispatcherProvider = TestDispatcherProvider(testDispatcher),
upgradeRepo = repo,
webpageTool = webpageTool,
)
private fun mockPurchase(skuId: String, autoRenewing: Boolean = false): Purchase = mockk<Purchase>().apply {
every { products } returns listOf(skuId)
every { isAutoRenewing } returns autoRenewing
every { purchaseTime } returns 1234L
}
private fun proInfo(vararg purchases: Purchase) = UpgradeRepoGplay.Info(
false,
BillingData(purchases = purchases.toList()),
null,
// Ownership data implies a committed reconciliation -> always settled.
isSettled = true,
)
/** Play answered. The default for restore mocks; use Inconclusive only to model a non-answer. */
private fun checked(info: UpgradeRepoGplay.Info) = UpgradeRepoGplay.RestoreOutcome.Checked(info)
@Test
fun `restore that finds a purchase emits RestoreSucceeded`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns checked(proInfo(mockPurchase("eu.darken.capod.iap.upgrade.pro")))
val vm = buildVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeEvents.RestoreSucceeded
}
@Test
fun `restore results are held back until the minimum visible duration`() = runTest2(context = testDispatcher) {
// The repo answers instantly here — the user must still see the check "run": the result
// event may only surface once RESTORE_MIN_VISIBLE_MS elapsed.
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns checked(proInfo(mockPurchase("eu.darken.capod.iap.upgrade.pro")))
val vm = buildVm(repo)
val received = mutableListOf<UpgradeEvents>()
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.events.collect { received.add(it) } }
vm.restorePurchase()
testScheduler.advanceTimeBy(UpgradeViewModel.RESTORE_MIN_VISIBLE_MS - 100)
testScheduler.runCurrent()
received.shouldBeEmpty()
testScheduler.advanceTimeBy(200)
testScheduler.runCurrent()
received shouldBe listOf<UpgradeEvents>(UpgradeEvents.RestoreSucceeded)
collector.cancel()
}
@Test
fun `restore with no purchase emits RestoreFailed`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns checked(UpgradeRepoGplay.Info(false, null, null))
val vm = buildVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeEvents.RestoreFailed
}
@Test
fun `restore that times out emits RestoreInconclusive not RestoreFailed`() = runTest2(context = testDispatcher) {
// A timeout proves nothing about ownership: the budget also covers connecting and the
// refresh mutex. RestoreFailed would assert a completed check and steer the user toward
// the multi-account explanation for what may just be a slow Play.
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(30_000) // longer than the 15s restore timeout
checked(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
}
val vm = buildVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeEvents.RestoreInconclusive
}
@Test
fun `a Play error absorbed by grace emits RestoreInconclusive not RestoreFailed`() = runTest2(
context = testDispatcher,
) {
// Same non-answer as a timeout, and the affected user is by definition a recent owner --
// exactly who must not be told Play was checked and had nothing.
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.RestoreOutcome.Inconclusive(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true),
RuntimeException("Play unavailable"),
)
val vm = buildVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeEvents.RestoreInconclusive
}
@Test
fun `restore that errors forwards the error instead of RestoreFailed`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
val boom = IllegalStateException("Play unavailable")
coEvery { repo.restorePurchaseNow() } throws boom
val vm = buildVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.restorePurchase()
advanceUntilIdle()
forwardedError.await() shouldBe boom
}
@Test
fun `previously-pro on this device flows into the loaded banner flag`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
coEvery { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } returns emptyList()
coEvery { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } returns emptyList()
val vm = buildVm(repo)
val loaded = async {
vm.state.first { it is GplayUpgradeUiState.Loaded } as GplayUpgradeUiState.Loaded
}
advanceUntilIdle()
loaded.await().wasPreviouslyPro shouldBe true
}
@Test
fun `banner flag stays off while grace still keeps the user pro`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
// gracePeriod = true => Info.isPro is true even without a current raw purchase.
every { repo.upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
every { repo.wasEverPro } returns MutableStateFlow(true)
coEvery { repo.querySkus(OurSku.Iap.PRO_UPGRADE) } returns emptyList()
coEvery { repo.querySkus(OurSku.Sub.PRO_UPGRADE) } returns emptyList()
val vm = buildVm(repo)
val loaded = async {
vm.state.first { it is GplayUpgradeUiState.Loaded } as GplayUpgradeUiState.Loaded
}
advanceUntilIdle()
loaded.await().wasPreviouslyPro shouldBe false
}
@Test
fun `restore is single-flight, taps during a running restore are ignored`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
checked(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
}
val vm = buildVm(repo)
vm.restorePurchase()
vm.restorePurchase()
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 1) { repo.restorePurchaseNow() }
}
@Test
fun `a finished restore allows a new attempt`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns checked(UpgradeRepoGplay.Info(false, null, null))
val vm = buildVm(repo)
vm.restorePurchase()
advanceUntilIdle()
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 2) { repo.restorePurchaseNow() }
}
@Test
fun `default route bounces a pro user out of the screen`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(proInfo(mockPurchase("upgrade.pro", autoRenewing = true)))
val vm = buildVm(repo)
val navEvents = mutableListOf<NavEvent>()
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.navEvents.collect { navEvents.add(it) } }
vm.bindRoute(Nav.Main.Upgrade())
advanceUntilIdle()
navEvents shouldBe listOf(NavEvent.Up)
collector.cancel()
}
@Test
fun `manage route keeps a pro user on the screen`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(proInfo(mockPurchase("upgrade.pro", autoRenewing = true)))
val vm = buildVm(repo)
val navEvents = mutableListOf<NavEvent>()
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.navEvents.collect { navEvents.add(it) } }
vm.bindRoute(Nav.Main.Upgrade(manage = true))
advanceUntilIdle()
navEvents.shouldBeEmpty()
collector.cancel()
}
@Test
fun `iap purchase is blocked while the subscription is still set to renew`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } returns listOf(mockPurchase("upgrade.pro", autoRenewing = true))
val vm = buildVm(repo)
val event = async { vm.events.first() }
vm.onGoIap(mockk<Activity>(relaxed = true))
advanceUntilIdle()
event.await() shouldBe UpgradeEvents.SubscriptionStillRenewing
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
}
@Test
fun `iap purchase proceeds when the subscription is not set to renew`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } returns listOf(mockPurchase("upgrade.pro", autoRenewing = false))
val vm = buildVm(repo)
vm.onGoIap(mockk<Activity>(relaxed = true))
advanceUntilIdle()
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), eq(OurSku.Iap.PRO_UPGRADE), isNull(), any()) }
}
@Test
fun `iap purchase proceeds without any subscription`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } returns emptyList()
val vm = buildVm(repo)
vm.onGoIap(mockk<Activity>(relaxed = true))
advanceUntilIdle()
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), eq(OurSku.Iap.PRO_UPGRADE), isNull(), any()) }
}
@Test
fun `failing subscription verification blocks the purchase and forwards the error`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
val boom = IllegalStateException("Play unavailable")
coEvery { repo.queryCurrentSubscriptions() } throws boom
val vm = buildVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.onGoIap(mockk<Activity>(relaxed = true))
advanceUntilIdle()
forwardedError.await() shouldBe boom
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
}
@Test
fun `subscription verification timeout blocks the purchase with a check-failed event`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(30_000) // longer than the 10s verification timeout
emptyList()
}
val vm = buildVm(repo)
val event = async { vm.events.first() }
vm.onGoIap(mockk<Activity>(relaxed = true))
advanceUntilIdle()
event.await() shouldBe UpgradeEvents.SubscriptionCheckFailed
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
}
@Test
fun `iap taps are single-flight while a verification is running`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(5_000)
emptyList()
}
val vm = buildVm(repo)
vm.onGoIap(mockk<Activity>(relaxed = true))
vm.onGoIap(mockk<Activity>(relaxed = true))
vm.onGoIap(mockk<Activity>(relaxed = true))
advanceUntilIdle()
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), eq(OurSku.Iap.PRO_UPGRADE), isNull(), any()) }
}
// A repo whose Play launch takes a while to resolve: the guard has to cover the whole
// tap-to-sheet window, so every arbiter test needs a launch that is actually in flight.
private fun UpgradeRepoGplay.withSlowLaunch(durationMs: Long = 5_000L) = apply {
coEvery { launchBillingFlowNow(any(), any(), any(), any()) } coAnswers { delay(durationMs) }
}
@Test
fun `subscription taps are single-flight`() = runTest2(context = testDispatcher) {
val repo = mockRepo().withSlowLaunch()
val vm = buildVm(repo)
// The old fire-and-forget path had no guard at all: every tap opened another Play sheet.
vm.onGoSubscription(mockk<Activity>(relaxed = true))
vm.onGoSubscription(mockk<Activity>(relaxed = true))
vm.onGoSubscriptionTrial(mockk<Activity>(relaxed = true))
advanceUntilIdle()
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), eq(OurSku.Sub.PRO_UPGRADE), any(), any()) }
}
@Test
fun `a running subscription launch blocks the iap and restore actions`() = runTest2(context = testDispatcher) {
val repo = mockRepo().withSlowLaunch()
val vm = buildVm(repo)
vm.onGoSubscription(mockk<Activity>(relaxed = true))
testScheduler.advanceTimeBy(1_000) // launch in flight
testScheduler.runCurrent()
vm.onGoIap(mockk<Activity>(relaxed = true))
vm.restorePurchase()
advanceUntilIdle()
// One arbiter for all three: the purchase and the restore would otherwise run concurrent
// Play operations against the same account state.
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
coVerify(exactly = 0) { repo.restorePurchaseNow() }
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
}
@Test
fun `a running restore blocks the purchase actions`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
checked(UpgradeRepoGplay.Info(false, null, null))
}
val vm = buildVm(repo)
vm.restorePurchase()
testScheduler.advanceTimeBy(1_000)
testScheduler.runCurrent()
vm.onGoSubscription(mockk<Activity>(relaxed = true))
vm.onGoIap(mockk<Activity>(relaxed = true))
advanceUntilIdle()
coVerify(exactly = 1) { repo.restorePurchaseNow() }
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
}
@Test
fun `the arbiter is released once the launch resolved`() = runTest2(context = testDispatcher) {
val repo = mockRepo().withSlowLaunch()
val vm = buildVm(repo)
vm.onGoSubscription(mockk<Activity>(relaxed = true))
advanceUntilIdle()
vm.onGoSubscription(mockk<Activity>(relaxed = true))
advanceUntilIdle()
coVerify(exactly = 2) { repo.launchBillingFlowNow(any(), eq(OurSku.Sub.PRO_UPGRADE), any(), any()) }
}
@Test
fun `a running subscription launch is exposed as the busy op`() = runTest2(context = testDispatcher) {
val repo = mockRepo().withSlowLaunch()
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.state.collect { } }
advanceUntilIdle()
vm.onGoSubscription(mockk<Activity>(relaxed = true))
testScheduler.advanceTimeBy(1_000)
testScheduler.runCurrent()
(vm.state.value as GplayUpgradeUiState.Loaded).busy shouldBe BusyOp.SUBSCRIPTION
advanceUntilIdle()
(vm.state.value as GplayUpgradeUiState.Loaded).busy shouldBe null
collector.cancel()
}
@Test
fun `a launch from another ViewModel instance blocks this one`() = runTest2(context = testDispatcher) {
// The launch lives on AppScope and outlives the ViewModel that started it, so after a
// rotation the fresh ViewModel must not start a second one.
val repo = mockRepo().withSlowLaunch()
val launchSku = MutableStateFlow<Sku?>(OurSku.Sub.PRO_UPGRADE)
every { repo.purchaseLaunchSku } returns launchSku
val vm = buildVm(repo)
vm.onGoSubscription(mockk<Activity>(relaxed = true))
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
coVerify(exactly = 0) { repo.restorePurchaseNow() }
// Once the foreign launch resolved, this ViewModel works normally again.
launchSku.value = null
vm.onGoSubscription(mockk<Activity>(relaxed = true))
advanceUntilIdle()
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
}
@Test
fun `subscription owner gets ownership state even when product details fail`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(proInfo(mockPurchase("upgrade.pro", autoRenewing = true)))
coEvery { repo.querySkus(any()) } throws IllegalStateException("No details available")
val vm = buildVm(repo)
val loaded = async {
vm.state.first { it is GplayUpgradeUiState.Loaded } as GplayUpgradeUiState.Loaded
}
advanceUntilIdle()
val ownership = loaded.await().ownership
ownership.hasIap shouldBe false
ownership.subscription.shouldNotBeNull().isAutoRenewing.shouldBeTrue()
}
@Test
fun `successful queries never render from an unsettled Info`() = runTest2(
context = testDispatcher,
) {
// The adversarial order behind the old flash: SKU queries finish BEFORE the reconciled
// Info propagates. The screen must hold at Loading instead of rendering acquisition UI
// from the pre-reconciliation seed — even though the queries are done.
val repo = mockRepo()
val infos = MutableStateFlow(UpgradeRepoGplay.Info(false, null, null))
every { repo.upgradeInfo } returns infos
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.state.collect { } }
testScheduler.advanceTimeBy(1_000)
vm.state.value shouldBe GplayUpgradeUiState.Loading
// The settled Info arrives (here: reconciled ownership) -> rendering proceeds.
infos.value = proInfo(mockPurchase("upgrade.pro"))
advanceUntilIdle()
vm.state.value.shouldBeInstanceOf<GplayUpgradeUiState.Loaded>()
collector.cancel()
}
@Test
fun `two failed queries resolve to unavailable without waiting for settled`() = runTest2(
context = testDispatcher,
) {
// Carve-out: a Done where BOTH fresh SKU queries failed is itself a definitive
// can't-reach-Play outcome — the Unavailable card keeps its ~15s worst-case bound from
// the query timeouts instead of also waiting out the connect loop's failure signal.
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(false, null, null))
coEvery { repo.querySkus(any()) } throws IllegalStateException("Play unavailable")
val vm = buildVm(repo)
val unavailable = async { vm.state.first { it is GplayUpgradeUiState.Unavailable } }
advanceUntilIdle()
unavailable.await().shouldBeInstanceOf<GplayUpgradeUiState.Unavailable>()
}
@Test
fun `settled owner renders ownership while queries are still pending`() = runTest2(
context = testDispatcher,
) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(proInfo(mockPurchase("upgrade.pro")))
coEvery { repo.querySkus(any()) } coAnswers {
delay(60_000) // effectively never within this test
emptyList()
}
val vm = buildVm(repo)
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.state.collect { } }
testScheduler.advanceTimeBy(1_000)
// Owners don't depend on offer prices: status renders immediately, never acquisition.
vm.state.value.shouldBeInstanceOf<GplayUpgradeUiState.Loaded>()
collector.cancel()
}
@Test
fun `manage subscription opens the play management page for our sub`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
val webpageTool = mockk<WebpageTool>(relaxed = true)
val vm = buildVm(repo, webpageTool)
vm.onManageSubscription()
verify { webpageTool.open(UpgradeViewModel.PLAY_SUBSCRIPTION_SITE) }
UpgradeViewModel.PLAY_SUBSCRIPTION_SITE shouldContain "sku=${OurSku.Sub.PRO_UPGRADE.id}"
UpgradeViewModel.PLAY_SUBSCRIPTION_SITE shouldContain "package="
}
@Test
fun `contact support navigates to the guided support form`() = runTest2(context = testDispatcher) {
val vm = buildVm(mockRepo())
val navEvents = mutableListOf<NavEvent>()
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.navEvents.collect { navEvents.add(it) } }
vm.onContactSupport()
advanceUntilIdle()
navEvents shouldBe listOf(NavEvent.GoTo(Nav.Settings.ContactSupport))
collector.cancel()
}
private suspend fun awaitLoaded(vm: UpgradeViewModel): GplayUpgradeUiState.Loaded =
vm.state.first { it is GplayUpgradeUiState.Loaded } as GplayUpgradeUiState.Loaded
@Test
fun `grace-only pro gets a quiet hint without diagnostics`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val loaded = async { awaitLoaded(vm) }
advanceUntilIdle()
val grace = loaded.await().grace
grace.shouldNotBeNull().showDiagnostics shouldBe false
}
@Test
fun `young grace episode keeps diagnostics hidden`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
every { repo.proUnconfirmedSince } returns MutableStateFlow(
System.currentTimeMillis() - Duration.ofHours(1).toMillis()
)
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val loaded = async { awaitLoaded(vm) }
advanceUntilIdle()
loaded.await().grace.shouldNotBeNull().showDiagnostics shouldBe false
}
@Test
fun `aged grace episode shows diagnostics`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
every { repo.proUnconfirmedSince } returns MutableStateFlow(
System.currentTimeMillis() - UpgradeViewModel.GRACE_DIAGNOSTICS_AFTER_MS - 1_000
)
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val loaded = async { awaitLoaded(vm) }
advanceUntilIdle()
loaded.await().grace.shouldNotBeNull().showDiagnostics shouldBe true
}
@Test
fun `plain non-pro users get no grace hint`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val loaded = async { awaitLoaded(vm) }
advanceUntilIdle()
loaded.await().grace shouldBe null
}
@Test
fun `owners get no grace hint`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(proInfo(mockPurchase("upgrade.pro", autoRenewing = true)))
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
val loaded = async { awaitLoaded(vm) }
advanceUntilIdle()
loaded.await().grace shouldBe null
}
@Test
fun `grace user keeps the grace card when both detail queries fail`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
// During an outage (exactly when grace matters) the price queries fail too — the user
// must keep the Loaded grace presentation, not get an acquisition-style Unavailable.
coEvery { repo.querySkus(any()) } throws IllegalStateException("Play unavailable")
val vm = buildVm(repo)
val loaded = async { awaitLoaded(vm) }
advanceUntilIdle()
loaded.await().grace.shouldNotBeNull()
}
@Test
fun `grace diagnostics appear when the episode crosses the threshold`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
val base = System.currentTimeMillis()
// Episode is 10 virtual seconds short of the threshold.
every { repo.proUnconfirmedSince } returns MutableStateFlow(
base - UpgradeViewModel.GRACE_DIAGNOSTICS_AFTER_MS + 10_000
)
coEvery { repo.querySkus(any()) } returns emptyList()
val vm = buildVm(repo)
var fakeNow = base
vm.clock = { fakeNow }
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.state.collect { } }
testScheduler.advanceTimeBy(1_000)
testScheduler.runCurrent()
(vm.state.value as GplayUpgradeUiState.Loaded).grace.shouldNotBeNull().showDiagnostics shouldBe false
// Cross the boundary: wall clock moves past it, then the scheduled tick re-evaluates.
fakeNow = base + 11_000
advanceUntilIdle()
(vm.state.value as GplayUpgradeUiState.Loaded).grace.shouldNotBeNull().showDiagnostics shouldBe true
collector.cancel()
}
@Test
fun `restore that only finds grace shows the troubleshooting dialog`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
// Grace keeps isPro=true, but no actual purchase came back — not a restore success.
coEvery { repo.restorePurchaseNow() } returns checked(UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true))
val vm = buildVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeEvents.RestoreFailed
}
@Test
fun `owner with failed detail queries gets no detail error dialog`() = runTest2(context = testDispatcher) {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(proInfo(mockPurchase("upgrade.pro", autoRenewing = true)))
coEvery { repo.querySkus(any()) } throws IllegalStateException("No details available")
val vm = buildVm(repo)
val errors = mutableListOf<Throwable>()
val errorCollector = launch(start = CoroutineStart.UNDISPATCHED) { vm.errorEvents.collect { errors.add(it) } }
val stateCollector = launch(start = CoroutineStart.UNDISPATCHED) { vm.state.collect { } }
advanceUntilIdle()
vm.state.value.shouldBeInstanceOf<GplayUpgradeUiState.Loaded>()
errors.shouldBeEmpty()
errorCollector.cancel()
stateCollector.cancel()
}
}
@@ -1,468 +0,0 @@
package eu.darken.capod.upgrade.ui
import android.app.Application
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
// Behavioral Compose tests for the offercard upgrade screen — offer visibility, enabled states,
// grace stages, restore surfaces and dialogs. Runs on the JVM via Robolectric (vintage engine
// under JUnit5).
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpgradeScreenComposeTest {
@get:Rule
val composeRule = createComposeRule()
// Mirrors toLoadedState's gating (incl. verification) so enabled-state assertions match the VM.
private fun loaded(
ownership: Ownership = Ownership(),
grace: GraceHint? = null,
showRestoreBanner: Boolean = false,
settled: Boolean = true,
restoreInProgress: Boolean = false,
verificationInProgress: Boolean = false,
subscriptionAction: SubscriptionAction = SubscriptionAction.TRIAL,
subscriptionPrice: String? = "€3.49",
iapPrice: String? = "€6.49",
) = UpgradeUiState.Loaded(
subscriptionAction = subscriptionAction,
subscriptionEnabled = settled && ownership.subscription == null && !restoreInProgress && !verificationInProgress,
subscriptionPrice = subscriptionPrice,
iapEnabled = settled && !ownership.hasIap && !restoreInProgress && !verificationInProgress,
iapPrice = iapPrice,
ownership = ownership,
grace = grace,
showRestoreBanner = showRestoreBanner,
settled = settled,
restoreInProgress = restoreInProgress,
verificationInProgress = verificationInProgress,
)
private fun setScreen(
state: UpgradeUiState,
onSubscription: () -> Unit = {},
onSubscriptionTrial: () -> Unit = {},
onIap: () -> Unit = {},
onRestore: () -> Unit = {},
onManageSubscription: () -> Unit = {},
onRetry: () -> Unit = {},
) {
composeRule.setContent {
UpgradeScreen(
state = state,
onNavigateUp = {},
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
onRestore = onRestore,
onManageSubscription = onManageSubscription,
onRetry = onRetry,
)
}
}
// --- No-offers fallback ---
private fun noOffers(skuQueryInProgress: Boolean = false, settled: Boolean = true) = loaded(
subscriptionAction = SubscriptionAction.UNAVAILABLE,
subscriptionPrice = null,
iapPrice = null,
settled = settled,
).copy(skuQueryInProgress = skuQueryInProgress)
@Test
fun `the no-offers fallback shows a Retry that fires the callback`() {
var retries = 0
setScreen(state = noOffers(), onRetry = { retries++ })
composeRule.onNodeWithTag(UpgradeScreenTags.RETRY_BUTTON)
.performScrollTo()
.assertIsEnabled()
.performClick()
retries shouldBe 1
}
@Test
fun `the no-offers fallback purchase button fires onIap`() {
var iapTapped = false
setScreen(state = noOffers(), onIap = { iapTapped = true })
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON)
.performScrollTo()
.assertIsEnabled()
.performClick()
iapTapped shouldBe true
}
@Test
fun `no offers while a query is running shows the settling spinner, not the unavailable card`() {
// The red "unavailable" card must not appear while offers are still being fetched.
setScreen(state = noOffers(skuQueryInProgress = true))
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_SETTLING).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.RETRY_BUTTON).assertDoesNotExist()
}
@Test
fun `no offers before billing has settled shows the settling spinner, not the unavailable card`() {
// On entry the account looks like a non-owner until Play answers; the red card must wait
// until we're actually sure Play returned nothing.
setScreen(state = noOffers(settled = false))
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_SETTLING).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist()
}
// --- Partial offer availability ---
@Test
fun `subscription-only offers hide the IAP row`() {
setScreen(loaded(iapPrice = null))
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist()
}
@Test
fun `iap-only offers hide the subscription row`() {
setScreen(loaded(subscriptionAction = SubscriptionAction.UNAVAILABLE, subscriptionPrice = null))
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).assertDoesNotExist()
}
@Test
fun `no offers at all shows the unavailable card`() {
setScreen(noOffers())
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS_UNAVAILABLE).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS).assertDoesNotExist()
}
// --- Offer routing ---
@Test
fun `the trial subscription routes to the trial callback`() {
var trial = 0
var standard = 0
setScreen(
loaded(subscriptionAction = SubscriptionAction.TRIAL),
onSubscription = { standard++ },
onSubscriptionTrial = { trial++ },
)
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().performClick()
trial shouldBe 1
standard shouldBe 0
}
@Test
fun `the standard subscription routes to the standard callback`() {
var trial = 0
var standard = 0
setScreen(
loaded(subscriptionAction = SubscriptionAction.STANDARD),
onSubscription = { standard++ },
onSubscriptionTrial = { trial++ },
)
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().performClick()
standard shouldBe 1
trial shouldBe 0
}
// --- Owner states ---
@Test
fun `owner with renewing sub sees status and a locked switch`() {
setScreen(loaded(ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true))))
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_HERO).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_SUB_CARD).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
// No sales pitch, no acquisition offers for owners.
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.OFFERS).assertDoesNotExist()
}
@Test
fun `owner with non-renewing sub can use the switch`() {
var iapTapped = false
setScreen(
loaded(ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false))),
onIap = { iapTapped = true },
)
val button = composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo()
button.assertIsEnabled()
button.performClick()
iapTapped shouldBe true
}
@Test
fun `the non-renewing switch stays enabled even when the IAP price is missing`() {
setScreen(
loaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
iapPrice = null,
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsEnabled()
}
@Test
fun `iap owner sees the ownership card but no switch or manage actions`() {
setScreen(loaded(ownership = Ownership(hasIap = true)))
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_HERO).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_IAP_CARD).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_CARD).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.MANAGE_SUB_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_WARNING).assertDoesNotExist()
}
@Test
fun `owning both while the sub still renews shows the double-payment warning`() {
setScreen(
loaded(
ownership = Ownership(
hasIap = true,
subscription = SubscriptionOwnership(isAutoRenewing = true),
),
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_WARNING).performScrollTo().assertIsDisplayed()
// Both products owned -> no switch offer.
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_CARD).assertDoesNotExist()
}
@Test
fun `manage subscription fires the callback`() {
var managed = false
setScreen(
loaded(ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true))),
onManageSubscription = { managed = true },
)
composeRule.onNodeWithTag(UpgradeScreenTags.MANAGE_SUB_BUTTON).performScrollTo().performClick()
managed shouldBe true
}
@Test
fun `the switch honors the settled gate`() {
setScreen(
loaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
settled = false,
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
}
@Test
fun `verification in progress disables the unlocked switch`() {
setScreen(
loaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)),
verificationInProgress = true,
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.SWITCH_BUTTON).performScrollTo().assertIsNotEnabled()
}
@Test
fun `verification in progress disables the owner restore`() {
setScreen(
loaded(
ownership = Ownership(subscription = SubscriptionOwnership(isAutoRenewing = true)),
verificationInProgress = true,
),
)
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BUTTON).performScrollTo().assertIsNotEnabled()
}
// --- Grace states ---
@Test
fun `quiet grace stage hides offers and restore`() {
setScreen(loaded(grace = GraceHint(showDiagnostics = false)))
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_CARD).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertDoesNotExist()
}
@Test
fun `diagnostics grace stage shows restore and brings the offers back`() {
var restoreTapped = false
setScreen(
loaded(grace = GraceHint(showDiagnostics = true)),
onRestore = { restoreTapped = true },
)
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_CARD).assertIsDisplayed()
// Offers return so an actually-expired subscriber can switch without waiting out grace.
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsDisplayed()
// No pitch next to the grace card.
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON).performScrollTo().performClick()
restoreTapped shouldBe true
}
@Test
fun `verification disables the grace restore`() {
setScreen(loaded(grace = GraceHint(showDiagnostics = true), verificationInProgress = true))
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_RESTORE_BUTTON).performScrollTo().assertIsNotEnabled()
}
// --- Acquisition states ---
@Test
fun `acquisition shows pitch and enabled purchase buttons`() {
setScreen(loaded())
composeRule.onNodeWithTag(UpgradeScreenTags.BENEFITS).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.OWNER_HERO).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.GRACE_CARD).assertDoesNotExist()
}
@Test
fun `same-phase state changes recompose the offers in place`() {
// Guards the AnimatedContent keying: a Loaded→Loaded change that keeps offers available
// must update the existing offer buttons, not re-run an enter transition or get stuck on a
// stale snapshot.
val state = mutableStateOf(loaded(settled = false))
composeRule.setContent {
UpgradeScreen(
state = state.value,
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
onManageSubscription = {},
onRetry = {},
)
}
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsNotEnabled()
state.value = loaded(settled = true)
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsEnabled()
}
@Test
fun `purchase buttons are disabled before billing has settled`() {
setScreen(loaded(settled = false))
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsNotEnabled()
}
@Test
fun `returning buyers get exactly one emphasized restore action`() {
var restored = false
setScreen(loaded(showRestoreBanner = true), onRestore = { restored = true })
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BANNER).performScrollTo().assertIsDisplayed()
// The emphasized banner action is the ONLY restore affordance — no ordinary section below.
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BUTTON).assertDoesNotExist()
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BANNER_ACTION).performScrollTo().performClick()
restored shouldBe true
}
@Test
fun `verification disables the emphasized returning-buyer restore`() {
setScreen(loaded(showRestoreBanner = true, verificationInProgress = true))
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BANNER_ACTION).performScrollTo().assertIsNotEnabled()
}
@Test
fun `restore in progress disables purchase and restore buttons`() {
setScreen(loaded(restoreInProgress = true))
composeRule.onNodeWithTag(UpgradeScreenTags.SUB_BUTTON).performScrollTo().assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.IAP_BUTTON).performScrollTo().assertIsNotEnabled()
composeRule.onNodeWithTag(UpgradeScreenTags.RESTORE_BUTTON).performScrollTo().assertIsNotEnabled()
}
// --- Dialogs ---
@Test
fun `still-renewing dialog offers the manage action`() {
var managed = false
var dismissed = false
composeRule.setContent {
StillRenewingDialog(onManage = { managed = true }, onDismiss = { dismissed = true })
}
composeRule.onNodeWithTag(UpgradeScreenTags.DIALOG_STILL_RENEWING).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.MANAGE_SUB_BUTTON).performClick()
managed shouldBe true
dismissed shouldBe false
}
@Test
fun `check-failed dialog renders`() {
composeRule.setContent {
CheckFailedDialog(onDismiss = {})
}
composeRule.onNodeWithTag(UpgradeScreenTags.DIALOG_CHECK_FAILED).assertIsDisplayed()
}
@Test
fun `restore-failed dialog contact support fires only its own callback`() {
var contacted = 0
var dismissed = 0
composeRule.setContent {
RestoreFailedDialog(onDismiss = { dismissed++ }, onContactSupport = { contacted++ })
}
composeRule.onNodeWithTag(UpgradeScreenTags.DIALOG_RESTORE_FAILED).assertIsDisplayed()
composeRule.onNodeWithTag(UpgradeScreenTags.CONTACT_SUPPORT_BUTTON).performClick()
contacted shouldBe 1
dismissed shouldBe 0
}
}
@@ -1,767 +0,0 @@
package eu.darken.capod.upgrade.ui
import android.app.Activity
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
import eu.darken.capod.common.upgrade.core.data.BillingData
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.coroutine.runTest2
import java.time.Duration
class UpgradeViewModelTest : BaseTest() {
private val timeSource = TestTimeSource()
private fun now(): Long = timeSource.currentTimeMillis()
private fun mockPurchase(productId: String, autoRenewing: Boolean = false): Purchase = mockk {
every { products } returns listOf(productId)
every { purchaseTime } returns 1_000L
every { isAutoRenewing } returns autoRenewing
}
private fun ownerInfo(vararg purchases: Purchase): UpgradeRepoGplay.Info {
val purchased = purchases.map { purchase ->
val sku = CapodSku.PRO_SKUS.first { it.id in purchase.products }
PurchasedSku(sku, purchase)
}
return UpgradeRepoGplay.Info(
billingData = BillingData(purchases.toList()),
upgrades = purchased,
isSettled = true,
)
}
private fun mockRepo(): UpgradeRepoGplay = mockk<UpgradeRepoGplay>(relaxed = true).apply {
// 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)
// 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)
coEvery { queryCurrentSubscriptions() } returns emptyList()
coEvery { querySkus(any()) } returns emptyList()
}
private fun TestScope.createVm(
repo: UpgradeRepoGplay,
manage: Boolean? = false,
) = UpgradeViewModel(
dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher(testScheduler)),
upgradeRepo = repo,
webpageTool = mockk<WebpageTool>(relaxed = true),
timeSource = timeSource,
).also { vm -> manage?.let { vm.initialize(it) } }
// --- Restore semantics ---
@Test
fun `restore with no purchase emits RestoreFailed`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(billingData = null)
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
}
@Test
fun `a grace-only restore result is NOT a success`() = runTest2 {
// isPro via grace means Play still couldn't confirm anything — the user must get the
// troubleshooting dialog, not a success message.
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(
gracePeriod = true,
billingData = null,
)
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
}
@Test
fun `restore that finds an actual purchase emits RestoreSucceeded`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns ownerInfo(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreSucceeded
}
@Test
fun `restore that times out emits RestoreFailed`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(UpgradeViewModel.RESTORE_TIMEOUT_MS * 2)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true)
}
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
}
@Test
fun `restore that errors forwards the error instead of RestoreFailed`() = runTest2 {
val repo = mockRepo()
val boom = IllegalStateException("Play unavailable")
coEvery { repo.restorePurchaseNow() } throws boom
val vm = createVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.restorePurchase()
advanceUntilIdle()
forwardedError.await() shouldBe boom
}
@Test
fun `restore results are padded to a minimum visible duration`() = runTest2 {
// The repo answers instantly here (warm cache) — the user must still see the check "run":
// the result event may only surface once RESTORE_MIN_VISIBLE_MS elapsed.
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(billingData = null)
val vm = createVm(repo)
val events = mutableListOf<UpgradeViewModel.UpgradeEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.events.collect { events.add(it) } }
vm.restorePurchase()
testScheduler.advanceTimeBy(UpgradeViewModel.RESTORE_MIN_VISIBLE_MS - 100)
testScheduler.runCurrent()
events shouldBe emptyList()
testScheduler.advanceTimeBy(200)
testScheduler.runCurrent()
events shouldBe listOf(UpgradeViewModel.UpgradeEvent.RestoreFailed)
job.cancel()
}
@Test
fun `restore is single-flight, taps during a running restore are ignored`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true)
}
val vm = createVm(repo)
vm.restorePurchase()
vm.restorePurchase()
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 1) { repo.restorePurchaseNow() }
}
@Test
fun `a finished restore allows a new attempt`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(billingData = null)
val vm = createVm(repo)
vm.restorePurchase()
advanceUntilIdle()
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 2) { repo.restorePurchaseNow() }
}
// --- Switch-to-IAP gate ---
@Test
fun `every IAP tap runs the fresh SUBS gate, even for an apparent non-owner`() = runTest2 {
// upgradeInfo says non-owner (stale/empty early state), but the fresh query finds a
// renewing subscription — the launch must stay blocked.
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } returns
listOf(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true))
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.SubscriptionStillRenewing
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `IAP launch proceeds when no subscription is renewing`() = runTest2 {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } returns
listOf(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = false))
val vm = createVm(repo)
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 1) { repo.launchBillingFlow(any(), CapodSku.Iap.PRO_UPGRADE, null) }
}
@Test
fun `a timed-out subscription check fails closed`() = runTest2 {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(UpgradeViewModel.VERIFY_TIMEOUT_MS * 2)
emptyList()
}
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.SubscriptionCheckFailed
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `a failed subscription check fails closed with an error dialog`() = runTest2 {
val repo = mockRepo()
val boom = IllegalStateException("Play unavailable")
coEvery { repo.queryCurrentSubscriptions() } throws boom
val vm = createVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
forwardedError.await() shouldBe boom
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `purchase actions are single-flight while the verification is suspended`() = runTest2 {
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(5_000)
emptyList()
}
val vm = createVm(repo)
vm.onGoIap(mockk<Activity>())
vm.onGoIap(mockk<Activity>())
vm.onGoSubscription(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
// Only the IAP launch after its successful verification; the sub tap was swallowed.
coVerify(exactly = 1) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `restore taps are ignored while a purchase action is in flight`() = runTest2 {
// Symmetric exclusion: otherwise a verification resolving to a dialog and a concurrent
// restore resolving to another dialog stack on screen.
val repo = mockRepo()
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
delay(5_000)
emptyList()
}
val vm = createVm(repo)
vm.onGoIap(mockk<Activity>())
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 0) { repo.restorePurchaseNow() }
}
@Test
fun `resume refreshes the subscription state for sub owners`() = runTest2 {
// Returning from Play's Manage page must unlock the switch promptly — the global
// foreground refresh is throttled to once an hour.
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
ownerInfo(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true))
)
val vm = createVm(repo)
vm.state.first { it is UpgradeUiState.Loaded }
vm.onResume()
advanceUntilIdle()
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
}
@Test
fun `resume does not query Play for non-subscribers`() = runTest2 {
val repo = mockRepo()
val vm = createVm(repo)
vm.state.first { it is UpgradeUiState.Loaded }
vm.onResume()
advanceUntilIdle()
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
}
@Test
fun `purchase taps are ignored while a restore is running`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true)
}
val vm = createVm(repo)
vm.restorePurchase()
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@Test
fun `user canceling the billing flow stays silent`() = runTest2 {
val repo = mockRepo()
coEvery { repo.launchBillingFlow(any(), any(), any()) } throws
UserCanceledBillingException(RuntimeException("launch result"))
val vm = createVm(repo)
val errors = mutableListOf<Throwable>()
val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } }
vm.onGoIap(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 1) { repo.launchBillingFlow(any(), any(), any()) }
errors shouldBe emptyList()
errorJob.cancel()
}
@Test
fun `billing flow launch errors are forwarded to the error dialog`() = runTest2 {
val repo = mockRepo()
val boom = IllegalStateException("launch failed")
coEvery { repo.launchBillingFlow(any(), any(), any()) } throws boom
val vm = createVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.onGoSubscription(mockk<Activity>())
advanceUntilIdle()
forwardedError.await() shouldBe boom
}
// --- Route handling / auto-close ---
@Test
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, isSettled = true)
)
val vm = createVm(repo, manage = null)
val navEvent = async { vm.navEvents.first() }
vm.initialize(manage = false)
advanceUntilIdle()
navEvent.await() shouldBe NavEvent.Up
}
@Test
fun `the manage route never auto-closes`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true)
)
val vm = createVm(repo, manage = true)
val navEvents = mutableListOf<NavEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.navEvents.collect { navEvents.add(it) } }
advanceUntilIdle()
navEvents shouldBe emptyList()
job.cancel()
}
@Test
fun `no auto-close before the route is bound`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true)
)
val vm = createVm(repo, manage = null)
val navEvents = mutableListOf<NavEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.navEvents.collect { navEvents.add(it) } }
advanceUntilIdle()
navEvents shouldBe emptyList()
job.cancel()
}
@Test
fun `contact support navigates to the contact form`() = runTest2 {
val repo = mockRepo()
val vm = createVm(repo)
val navEvent = async { vm.navEvents.first() }
vm.onContactSupport()
advanceUntilIdle()
val event = navEvent.await()
event.shouldBeInstanceOf<NavEvent.GoTo>()
event.destination shouldBe Nav.Settings.ContactSupport
}
// --- State mapping ---
@Test
fun `owners render price-independently while SKU queries hang`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
ownerInfo(mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true))
)
coEvery { repo.querySkus(any()) } coAnswers { awaitCancellation() }
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded }
state.shouldBeInstanceOf<UpgradeUiState.Loaded>()
state.ownership.subscription.shouldNotBeNull()
state.ownership.subscription!!.isAutoRenewing shouldBe true
state.iapPrice.shouldBeNull()
}
@Test
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, isSettled = true)
)
every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(1).toMillis())
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.grace.shouldNotBeNull()
state.grace!!.showDiagnostics shouldBe false
}
@Test
fun `grace escalates to diagnostics after the threshold`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true)
)
every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(25).toMillis())
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.grace.shouldNotBeNull()
state.grace!!.showDiagnostics shouldBe true
}
@Test
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, isSettled = true)
)
// 2 minutes before the diagnostics threshold.
every { repo.proUnconfirmedSince } returns MutableStateFlow(
now() - UpgradeViewModel.GRACE_DIAGNOSTICS_AFTER_MS + Duration.ofMinutes(2).toMillis()
)
val vm = createVm(repo)
val states = mutableListOf<UpgradeUiState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect { states.add(it) } }
// runCurrent, NOT advanceUntilIdle: idling would fast-forward the virtual clock through
// the tick's delay while the wall-clock TestTimeSource still sits before the threshold.
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).grace!!.showDiagnostics shouldBe false
// All other combined flows are distinct-until-changed — only the tick can re-fire.
timeSource.advanceBy(Duration.ofMinutes(3))
testScheduler.advanceTimeBy(Duration.ofMinutes(3).toMillis())
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).grace!!.showDiagnostics shouldBe true
job.cancel()
}
@Test
fun `no grace hint for confirmed owners`() = runTest2 {
val repo = mockRepo()
every { repo.upgradeInfo } returns MutableStateFlow(
ownerInfo(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(25).toMillis())
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.grace.shouldBeNull()
state.ownership.hasIap shouldBe true
}
@Test
fun `banner shows for a previously-pro install that is no longer pro`() = runTest2 {
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.showRestoreBanner shouldBe true
}
@Test
fun `banner stays hidden while grace still keeps the user pro`() = runTest2 {
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null, isSettled = true)
)
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.showRestoreBanner shouldBe false
}
@Test
fun `purchase buttons stay disabled until billing has settled`() = runTest2 {
val repo = mockRepo()
val infoFlow = MutableStateFlow<UpgradeRepo.Info>(
UpgradeRepoGplay.Info(billingData = null, isSettled = false)
)
every { repo.upgradeInfo } returns infoFlow
val vm = createVm(repo)
val states = mutableListOf<UpgradeUiState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect { states.add(it) } }
// runCurrent, NOT advanceUntilIdle: idling would run the bounded settle-fallback timer
// and defeat the point of the test.
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe false
(states.last() as UpgradeUiState.Loaded).subscriptionEnabled shouldBe false
infoFlow.value = UpgradeRepoGplay.Info(billingData = null, isSettled = true)
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe true
job.cancel()
}
// --- Pure mappers ---
@Test
fun `toOwnership is conservative about auto-renewal`() {
// Two records for the sub SKU, one still claiming renewal -> treated as renewing.
val renewing = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = true)
val notRenewing = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = false)
val info = ownerInfo(notRenewing, renewing)
val ownership = info.toOwnership()
ownership.subscription.shouldNotBeNull()
ownership.subscription!!.isAutoRenewing shouldBe true
ownership.hasIap shouldBe false
}
@Test
fun `toOwnership maps both product types`() {
val info = ownerInfo(
mockPurchase(CapodSku.Iap.PRO_UPGRADE.id),
mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, autoRenewing = false),
)
val ownership = info.toOwnership()
ownership.hasIap shouldBe true
ownership.subscription.shouldNotBeNull()
ownership.subscription!!.isAutoRenewing shouldBe false
ownership.ownsAnything shouldBe true
}
@Test
fun `toLoadedState disables owned products`() {
val state = toLoadedState(
skus = SkuQueryState(done = true),
ownership = Ownership(hasIap = true, subscription = SubscriptionOwnership(isAutoRenewing = true)),
grace = null,
showRestoreBanner = false,
settled = true,
restoreInProgress = false,
verificationInProgress = false,
)
state.iapEnabled shouldBe false
state.subscriptionEnabled shouldBe false
}
@Test
fun `toLoadedState disables the buy actions during a restore`() {
val state = toLoadedState(
skus = SkuQueryState(done = true),
ownership = Ownership(),
grace = null,
showRestoreBanner = false,
settled = true,
restoreInProgress = true,
verificationInProgress = false,
)
state.iapEnabled shouldBe false
state.subscriptionEnabled shouldBe false
}
// --- SKU-query retry (P3) ---
@Test
fun `a slow but healthy Play store waits for the query instead of tripping the timeout`() = runTest2 {
// 9s is under the 15s SKU-query timeout — the store is slow, not broken. Prove the screen
// WAITED for the slow query (still Loading at 5s) and only loaded once it answered at 9s; a
// shorter timeout would have flipped to Loaded early with null details.
val repo = mockRepo()
coEvery { repo.querySkus(any()) } coAnswers {
delay(9_000)
emptyList()
}
val vm = createVm(repo)
val states = mutableListOf<UpgradeUiState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect { states.add(it) } }
testScheduler.advanceTimeBy(5_000)
testScheduler.runCurrent()
states.last().shouldBeInstanceOf<UpgradeUiState.Loading>()
testScheduler.advanceTimeBy(4_500)
testScheduler.runCurrent()
states.last().shouldBeInstanceOf<UpgradeUiState.Loaded>()
job.cancel()
}
@Test
fun `retry re-runs the SKU queries`() = runTest2 {
val repo = mockRepo()
val vm = createVm(repo)
vm.state.first { it is UpgradeUiState.Loaded }
vm.retrySkuQuery()
advanceUntilIdle()
// Initial aggregate query + the retried generation.
coVerify(exactly = 2) { repo.querySkus(CapodSku.Iap.PRO_UPGRADE) }
}
@Test
fun `retry is disabled while a SKU query is still running`() = runTest2 {
// Grace users render the fallback + Retry price-independently while the query runs; the
// 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, isSettled = true)
)
every { repo.proUnconfirmedSince } returns MutableStateFlow(now() - Duration.ofHours(25).toMillis())
coEvery { repo.querySkus(any()) } coAnswers { awaitCancellation() }
val vm = createVm(repo)
val state = vm.state.first { it is UpgradeUiState.Loaded } as UpgradeUiState.Loaded
state.skuQueryInProgress shouldBe true
}
// --- autoRestoreBusy gate (P2) ---
@Test
fun `the invisible auto-restore disables the buy buttons`() = runTest2 {
val repo = mockRepo()
val autoBusy = MutableStateFlow(false)
every { repo.autoRestoreBusy } returns autoBusy
val vm = createVm(repo)
val states = mutableListOf<UpgradeUiState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect { states.add(it) } }
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe true
autoBusy.value = true
testScheduler.runCurrent()
(states.last() as UpgradeUiState.Loaded).iapEnabled shouldBe false
(states.last() as UpgradeUiState.Loaded).restoreInProgress shouldBe true
job.cancel()
}
@Test
fun `a purchase tap during the invisible auto-restore is refused authoritatively`() = runTest2 {
// Button disabling lags a tap; the authoritative gate in runExclusive must refuse a tap
// dispatched while the silent restore runs, or a subscribe would buy on top of the owned IAP.
val repo = mockRepo()
every { repo.autoRestoreBusy } returns MutableStateFlow(true)
val vm = createVm(repo)
vm.onGoSubscription(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
}
+2 -2
View File
@@ -147,8 +147,8 @@ fun DependencyHandlerScope.addTesting() {
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
testImplementation("io.mockk:mockk:1.12.4")
androidTestImplementation("io.mockk:mockk-android:1.12.4")
testImplementation("io.mockk:mockk:1.14.9")
androidTestImplementation("io.mockk:mockk-android:1.14.9")
testImplementation("io.kotest:kotest-runner-junit5:4.6.4")
testImplementation("io.kotest:kotest-assertions-core-jvm:4.6.4")