mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(upgrade): Show restore banner and progress for returning Pro buyers
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user