feat(upgrade): Show restore banner and progress for returning Pro buyers

This commit is contained in:
Matthias Urhahn
2026-07-11 12:05:06 +02:00
committed by GitHub
parent 69febaac28
commit b52c2cbe6f
5 changed files with 247 additions and 19 deletions
@@ -70,6 +70,10 @@ class UpgradeRepoGplay @Inject constructor(
}
.shareIn(scope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
// 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.
val wasEverPro: Flow<Boolean> = billingCache.lastProStateAt.flow.map { it > 0 }
// 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".
@@ -72,6 +72,7 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
val context = LocalContext.current
val activity = context as? Activity
val state by vm.state.collectAsState()
val restoreState by vm.restoreState.collectAsState()
LaunchedEffect(Unit) {
vm.events.collect { event ->
@@ -100,6 +101,7 @@ fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
UpgradeScreen(
state = state,
restoreState = restoreState,
onNavigateUp = { vm.navUp() },
onSubscription = { vm.onGoSubscription() },
onSubscriptionTrial = { vm.onGoSubscriptionTrial() },
@@ -118,6 +120,7 @@ fun UpgradeScreen(
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
onRestore: () -> Unit,
restoreState: UpgradeViewModel.RestoreState = UpgradeViewModel.RestoreState(),
) {
val benefits = listOf(
Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
@@ -185,6 +188,15 @@ fun UpgradeScreen(
Spacer(modifier = Modifier.height(24.dp))
if (restoreState.showRestoreBanner) {
RestoreBanner(
onRestore = onRestore,
restoreInProgress = restoreState.restoreInProgress,
)
Spacer(modifier = Modifier.height(24.dp))
}
Card(
modifier = Modifier.fillMaxWidth(),
) {
@@ -235,6 +247,7 @@ fun UpgradeScreen(
} else {
PricingContent(
state = state,
restoreInProgress = restoreState.restoreInProgress,
onSubscription = onSubscription,
onSubscriptionTrial = onSubscriptionTrial,
onIap = onIap,
@@ -260,9 +273,50 @@ fun UpgradeScreen(
}
}
@Composable
private fun RestoreBanner(
onRestore: () -> Unit,
restoreInProgress: Boolean,
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.upgrade_screen_restore_banner_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.upgrade_screen_restore_banner_body),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onRestore,
enabled = !restoreInProgress,
modifier = Modifier.fillMaxWidth(),
) {
if (restoreInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action))
}
}
}
}
@Composable
private fun PricingContent(
state: UpgradeViewModel.Pricing,
restoreInProgress: Boolean,
onSubscription: () -> Unit,
onSubscriptionTrial: () -> Unit,
onIap: () -> Unit,
@@ -280,6 +334,7 @@ private fun PricingContent(
Button(
onClick = subscriptionAction,
enabled = !restoreInProgress,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
@@ -313,6 +368,7 @@ private fun PricingContent(
if (state.iapAvailable) {
FilledTonalButton(
onClick = onIap,
enabled = !restoreInProgress,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
@@ -338,6 +394,7 @@ private fun PricingContent(
if (!state.subAvailable && !state.iapAvailable) {
Button(
onClick = onIap,
enabled = !restoreInProgress,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
@@ -371,11 +428,19 @@ private fun PricingContent(
OutlinedButton(
onClick = onRestore,
enabled = !restoreInProgress,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
) {
if (restoreInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = stringResource(R.string.upgrade_screen_restore_purchase_action))
}
}
@@ -396,3 +461,21 @@ private fun UpgradeScreenPreview() = PreviewWrapper {
onRestore = {},
)
}
@Preview2
@Composable
private fun UpgradeScreenReturningBuyerPreview() = PreviewWrapper {
UpgradeScreen(
state = UpgradeViewModel.Pricing(
subPrice = "€3.49",
iapPrice = "€6.49",
hasTrialOffer = true,
),
restoreState = UpgradeViewModel.RestoreState(showRestoreBanner = true),
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
)
}
@@ -16,8 +16,10 @@ 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.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
@@ -55,9 +57,30 @@ class UpgradeViewModel @Inject constructor(
val iapAvailable: Boolean get() = iap != null || iapPrice != null
}
// Restore affordances shown alongside the pricing state. Kept as a separate reactive state so
// the one-shot pricing query isn't re-run whenever upgradeInfo or the restore flag changes.
data class RestoreState(
val showRestoreBanner: Boolean = false,
val restoreInProgress: Boolean = false,
)
val events = SingleEventFlow<UpgradeEvent>()
val billingEvents = SingleEventFlow<BillingEvent>()
private val restoring = MutableStateFlow(false)
val restoreState: StateFlow<RestoreState> = combine(
upgradeRepo.wasEverPro,
upgradeRepo.upgradeInfo,
restoring,
) { wasEverPro, info, isRestoring ->
RestoreState(
// Hidden while a grace period or an actual purchase keeps the user Pro.
showRestoreBanner = wasEverPro && !info.isPro,
restoreInProgress = isRestoring,
)
}.stateIn(vmScope, SharingStarted.WhileSubscribed(5_000), RestoreState())
val state: StateFlow<Pricing?> = flow {
val iapDetails = try {
withTimeoutOrNull(5_000L) {
@@ -149,6 +172,12 @@ class UpgradeViewModel @Inject constructor(
}
private fun launchBillingFlow(activity: Activity, sku: Sku, offer: Sku.Subscription.Offer?) = launch {
// The disabled buy buttons are best-effort (recomposition lags the flag) — this is the
// authoritative guard against starting a purchase while a restore is still running.
if (restoring.value) {
log(TAG) { "launchBillingFlow(${sku.id}) ignored, restore in progress" }
return@launch
}
try {
upgradeRepo.launchBillingFlow(activity, sku, offer)
} catch (e: CancellationException) {
@@ -163,10 +192,31 @@ class UpgradeViewModel @Inject constructor(
}
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 messages.
if (!restoring.compareAndSet(expect = false, update = true)) {
log(TAG) { "restorePurchase() ignored, already in progress" }
return@launch
}
log(TAG, INFO) { "restorePurchase()" }
val restored = try {
withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
try {
val restored = withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
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)
}
restored.isPro -> log(TAG, INFO) { "Restored purchase :))" }
else -> {
log(TAG, WARN) { "No pro purchase found" }
events.tryEmit(UpgradeEvent.RestoreFailed)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -174,23 +224,9 @@ class UpgradeViewModel @Inject constructor(
// 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)
return@launch
}
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)
}
restored.isPro -> log(TAG, INFO) { "Restored purchase :))" }
else -> {
log(TAG, WARN) { "No pro purchase found" }
events.tryEmit(UpgradeEvent.RestoreFailed)
}
} finally {
// Reset only after result handling, so the single-flight guard covers the whole action.
restoring.value = false
}
}