diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index d63a481bbb..08a2f70343 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -205,6 +205,12 @@ final class BadgeStore: ObservableObject { await loadBadgeStateAsync(userId) do { let outcome = try await storePurchase(product, invoiceId) + if product.type == .autoRenewable { + switch outcome { + case .purchased, .pending: await MainActor.run { subscribedBefore = true } + case .cancelled: break + } + } if case let .purchased(receipt) = outcome, receipt.signatureVerified { try await handOver(receipt, listed: false) } @@ -302,9 +308,9 @@ final class BadgeStore: ObservableObject { for await verification in Transaction.unfinished { await reconcileListed(verification) } - await reportSubscriptionStates() await loadCurrentBadgeState() await MainActor.run { reconciledOnce = true } + await reportSubscriptionStates() } // transactions the store settles outside a purchase call, such as an approved Ask to Buy @@ -339,6 +345,7 @@ final class BadgeStore: ObservableObject { private func reconcile(_ verification: VerificationResult, listed: Bool) async throws { let receipt = storeReceipt(verification) let t = receipt.transaction + if t.productType == .autoRenewable { await MainActor.run { subscribedBefore = true } } if t.productType == .autoRenewable && t.revocationDate != nil { // the service refuses a revoked charge as invalid, and that would refuse the whole subscription's record await t.finish() @@ -352,6 +359,7 @@ final class BadgeStore: ObservableObject { // The service asks the store only once the paid period ends, so a cancellation or a failed charge before // then is seen only here. Sent with the latest transaction, which names the subscription as its first one did. func reportSubscriptionStates() async { + guard await hasSubscribedBefore() else { return } await load() guard let subscription = await MainActor.run(body: { products.values.first { $0.type == .autoRenewable }?.subscription }) else { return } do { @@ -392,7 +400,7 @@ final class BadgeStore: ObservableObject { } // Hands over as the device's own whatever the store lists, so a purchase no record here has is claimed by this profile. - // Answers whether a badge is shown or a store purchase is open afterwards. + // Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards. func restorePurchases() async throws -> Bool { try await AppStore.sync() var handOverError: Error? = nil @@ -408,7 +416,7 @@ final class BadgeStore: ObservableObject { return await MainActor.run { let userId = ChatModel.shared.currentUser?.userId let badgeShown = BadgeModel.shared.userId == userId && BadgeModel.shared.badgeState?.shown == true - return badgeShown || !openStorePurchases(userId).isEmpty + return badgeShown || purchaseState(userId) != nil } } diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index 91bdeaa672..bba0fcd128 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -183,6 +183,7 @@ struct BadgesHowLongView: View { private func continueButton() -> some View { ZStack { Button { + chosenPeriod = selectedPeriod continueActive = true } label: { Text("Continue") diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 6f3c22e1ae..fce59d3145 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -107,9 +107,15 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc // and either awaiting a slow payment suspend fun unfinishedBadgePurchases(): List { val client = connectedBadgeBillingClient() - val purchases = queryBadgePurchases(client, BillingClient.ProductType.INAPP) + - queryBadgePurchases(client, BillingClient.ProductType.SUBS) - return purchases.mapNotNull(::badgePurchaseOutcome) + val oneTime = queryBadgePurchases(client, BillingClient.ProductType.INAPP) + // a failed subscription query must not cost the one-time purchases their sweep + val subscriptions = try { + queryBadgePurchases(client, BillingClient.ProductType.SUBS) + } catch (e: BadgeStoreError.BillingError) { + Log.e(TAG, "unfinishedBadgePurchases: subscriptions ${e.message}") + emptyList() + } + return (oneTime + subscriptions).mapNotNull(::badgePurchaseOutcome) } private suspend fun queryBadgePurchases(client: BillingClient, productType: String): List { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index f3c1661d67..5446018ddc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -365,11 +365,11 @@ object BadgeStore { Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") } } - reportSubscriptionStates() chatModel.controller.loadBadgeState(chatModel.remoteHostId()) } finally { withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true } } + reportSubscriptionStates() } // Play lists a Google account's purchases on every device signed into it, so these are handed over as listed @@ -410,7 +410,10 @@ object BadgeStore { // the store's purchases are this device's, so they are told to its own core if (chatModel.remoteHostId() != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return try { - for (outcome in platform.androidUnfinishedBadgePurchases()) { + val purchases = platform.androidUnfinishedBadgePurchases() + if (purchases.none { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.autoRenewing != null }) return + load() + for (outcome in purchases) { val receipt = (outcome as? BadgePurchaseOutcome.Purchased)?.receipt ?: continue val state = when (receipt.autoRenewing) { true -> BadgeSubscriptionState.Renewing @@ -434,7 +437,7 @@ object BadgeStore { } // Hands over as the device's own whatever the store lists, so a purchase no record here has is claimed by this profile. - // Answers whether a badge is shown or a store purchase is open afterwards. + // Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards. suspend fun restorePurchases(): Boolean { var handOverError: Exception? = null val purchases = if (useBadgeTestProducts) emptyList() else platform.androidUnfinishedBadgePurchases() @@ -454,7 +457,7 @@ object BadgeStore { return withContext(Dispatchers.Main) { val userId = chatModel.currentUser.value?.userId val badgeShown = BadgeModel.isCurrent(rhId, userId) && BadgeModel.badgeState.value?.shown == true - badgeShown || openStorePurchases(userId).isNotEmpty() + badgeShown || purchaseState(userId) != null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index 7faf70d4fc..db1586853a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -123,7 +123,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDep Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally) { - ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) + ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) { chosenPeriod = it } BadgeBillingFooter(selectedPeriod) } } @@ -188,12 +188,13 @@ private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null @Composable -private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { +private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int, choosePeriod: (BadgePeriod) -> Unit) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.badges_continue, onboarding = null, onclick = { + choosePeriod(selectedPeriod) modalManager.showModal(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) } } ) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 202a0a080a..acba87ee97 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5867,8 +5867,8 @@ requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, badgeType, purchase storedAt <- badgeNow (applied, balance_) <- withStore' $ \db -> do applied <- applyBadgeStatement db g badgePurchaseId badgeType statement verifiedCred_ storedAt - -- the service reads the store only for a request whose asserted coverage had ended when it was made; - -- to any other it repeats the state it stored, which for an annual plan can be a year old + -- past the asserted coverage the state is the store's, except within an hour of an earlier check, when it can be + -- the last successful check's (a later fresh answer or device report corrects it); before then it can be a year old when (L.paidThrough lastEntry <= now) $ forM_ subscription $ setBadgeSubscriptionStatus db badgePurchaseId . boundedSubscriptionState (applied,) <$> getBadgeLedgerLastEntry db badgePurchaseId -- the statement is applied either way, so a month can be spent with nothing to show for it