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
}
}
+2
View File
@@ -43,6 +43,8 @@
<string name="upgrade_screen_restore_troubleshooting_msg">If you\'ve recently purchased, it may take a moment for Google Play to sync.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Try again in a few minutes if your purchase doesn\'t appear.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Make sure you\'re signed in with the same Google account used for the purchase.</string>
<string name="upgrade_screen_restore_banner_title">Already bought Pro?</string>
<string name="upgrade_screen_restore_banner_body">It looks like you upgraded to Pro on this device before. Restore your purchase to unlock it again.</string>
<string name="settings_monitor_connected_notification_label">Extra notification</string>
<string name="settings_monitor_connected_notification_description">Shows an extra notification when a device is connected. This lets you hide the permanent \"No devices\" notification by disabling the \"Device status\" channel.</string>
@@ -25,6 +25,7 @@ class UpgradeViewModelTest : BaseTest() {
private fun mockRepo(): UpgradeRepoGplay = mockk<UpgradeRepoGplay>(relaxed = true).apply {
every { upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(billingData = null))
every { wasEverPro } returns MutableStateFlow(false)
}
private fun TestScope.createVm(repo: UpgradeRepoGplay) = UpgradeViewModel(
@@ -100,6 +101,108 @@ class UpgradeViewModelTest : BaseTest() {
forwardedError.await() shouldBe boom
}
@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)
}
val vm = createVm(repo)
vm.restorePurchase()
vm.restorePurchase()
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 1) { repo.restorePurchaseNow() }
}
@Test
fun `restoreInProgress is set while a restore is running and cleared after`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(5_000)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
}
val vm = createVm(repo)
val states = mutableListOf<UpgradeViewModel.RestoreState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } }
vm.restorePurchase()
advanceUntilIdle()
states.any { it.restoreInProgress } shouldBe true
states.last().restoreInProgress shouldBe false
job.cancel()
}
@Test
fun `buy 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)
}
val vm = createVm(repo)
vm.restorePurchase()
vm.launchBillingIap(mockk<Activity>())
advanceUntilIdle()
coVerify(exactly = 0) { repo.launchBillingFlow(any(), any(), any()) }
}
@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() }
}
@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 states = mutableListOf<UpgradeViewModel.RestoreState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } }
advanceUntilIdle()
states.last().showRestoreBanner shouldBe true
job.cancel()
}
@Test
fun `banner stays hidden while grace still keeps the user pro`() = runTest2 {
val repo = mockRepo()
every { repo.wasEverPro } returns MutableStateFlow(true)
// gracePeriod = true -> isPro is true even without a current raw purchase.
every { repo.upgradeInfo } returns MutableStateFlow(
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
)
val vm = createVm(repo)
val states = mutableListOf<UpgradeViewModel.RestoreState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.restoreState.collect { states.add(it) } }
advanceUntilIdle()
states.last().showRestoreBanner shouldBe false
job.cancel()
}
@Test
fun `user canceling the billing flow stays silent`() = runTest2 {
val repo = mockRepo()