From b52c2cbe6f21223c64d301f3d52d7c1a69d768a8 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Sat, 11 Jul 2026 12:05:06 +0200 Subject: [PATCH] feat(upgrade): Show restore banner and progress for returning Pro buyers --- .../common/upgrade/core/UpgradeRepoGplay.kt | 4 + .../darken/capod/upgrade/ui/UpgradeScreen.kt | 83 ++++++++++++++ .../capod/upgrade/ui/UpgradeViewModel.kt | 74 +++++++++---- app/src/main/res/values/strings.xml | 2 + .../capod/upgrade/ui/UpgradeViewModelTest.kt | 103 ++++++++++++++++++ 5 files changed, 247 insertions(+), 19 deletions(-) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index 890ea64f..bb05b135 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -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 = 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". diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index 4d7db472..c1e5d5c0 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -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 = {}, + ) +} diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt index 05b8a90e..beeb9ace 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModel.kt @@ -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() val billingEvents = SingleEventFlow() + private val restoring = MutableStateFlow(false) + + val restoreState: StateFlow = 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 = 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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c872e5f..1906a8c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,8 @@ If you\'ve recently purchased, it may take a moment for Google Play to sync. Try again in a few minutes if your purchase doesn\'t appear. Make sure you\'re signed in with the same Google account used for the purchase. + Already bought Pro? + It looks like you upgraded to Pro on this device before. Restore your purchase to unlock it again. Extra notification Shows an extra notification when a device is connected. This lets you hide the permanent \"No devices\" notification by disabling the \"Device status\" channel. diff --git a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt index 7d07d838..9b5a1154 100644 --- a/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/upgrade/ui/UpgradeViewModelTest.kt @@ -25,6 +25,7 @@ class UpgradeViewModelTest : BaseTest() { private fun mockRepo(): UpgradeRepoGplay = mockk(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() + 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()) + 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() + 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() + 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()