mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
+5
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
+16
@@ -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),
|
||||
)
|
||||
}
|
||||
+16
@@ -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),
|
||||
)
|
||||
}
|
||||
+3
-5
@@ -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),
|
||||
)
|
||||
}
|
||||
+6
-5
@@ -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),
|
||||
)
|
||||
}
|
||||
+28
@@ -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
-1
@@ -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
|
||||
|
||||
+8
@@ -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)
|
||||
+10
@@ -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})"
|
||||
}
|
||||
+31
@@ -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}]" }
|
||||
+551
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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")
|
||||
}
|
||||
}
|
||||
-379
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-200
@@ -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")
|
||||
}
|
||||
}
|
||||
-18
@@ -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
|
||||
-18
@@ -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,
|
||||
)
|
||||
-5
@@ -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()
|
||||
}
|
||||
+2
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+101
@@ -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()
|
||||
}
|
||||
}
|
||||
+68
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -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
|
||||
}
|
||||
}
|
||||
+55
@@ -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"
|
||||
}
|
||||
}
|
||||
+797
-825
File diff suppressed because it is too large
Load Diff
+1085
File diff suppressed because it is too large
Load Diff
+35
@@ -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"
|
||||
}
|
||||
}
|
||||
+53
@@ -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 "[]"
|
||||
}
|
||||
}
|
||||
+978
@@ -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
|
||||
}
|
||||
-373
@@ -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)
|
||||
}
|
||||
}
|
||||
-451
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+884
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user