From f636d2d3f5af4fb3a88635f9cb6c59a56f5b1ee1 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:07:06 +0400 Subject: [PATCH] ios, android: tell whoever is looking when a purchase is refused --- apps/ios/Shared/Views/Badges/BadgeStore.swift | 11 +++-------- apps/ios/Shared/Views/Badges/BadgesView.swift | 6 ++++++ .../chat/simplex/common/views/badges/BadgeStore.kt | 13 ++++--------- .../chat/simplex/common/views/badges/BadgesView.kt | 10 ++++++++++ 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 6237c49740..5d9ea23c17 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -66,7 +66,6 @@ struct BadgeStoreReceipt { // the signed token the badge service verifies - never transaction.jsonRepresentation let jws: String let productId: String - let transactionId: UInt64 let invoiceId: UUID? let signatureVerified: Bool let transaction: Transaction @@ -112,8 +111,7 @@ final class BadgeStore: ObservableObject { // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again @Published private var reconciledOnce = false - // purchases the user started this run, by transaction: the store is not relied on to echo the invoice back - private var awaitedTransactions: Set = [] + let refusals = PassthroughSubject() private var transactionUpdates: Task? = nil private init() {} @@ -209,7 +207,6 @@ final class BadgeStore: ObservableObject { do { let outcome = try await storePurchase(product, invoiceId) if case let .purchased(receipt) = outcome, receipt.signatureVerified { - await MainActor.run { _ = awaitedTransactions.insert(receipt.transactionId) } await handOver(receipt) } await MainActor.run { buying = false } @@ -264,9 +261,8 @@ final class BadgeStore: ObservableObject { private func settle(_ receipt: BadgeStoreReceipt, refusal: Error?) async { await receipt.transaction.finish() - let awaited = await MainActor.run { awaitedTransactions.remove(receipt.transactionId) != nil } - if awaited, let refusal { - await MainActor.run { showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(refusal, purchase: true)) } + if let refusal { + await MainActor.run { refusals.send(refusal) } } } @@ -333,7 +329,6 @@ private func storeReceipt(_ verification: VerificationResult) -> Ba return BadgeStoreReceipt( jws: verification.jwsRepresentation, productId: t.productID, - transactionId: t.id, invoiceId: t.appAccountToken, signatureVerified: signatureVerified, transaction: t diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index 2d37860c45..28fa1a02e8 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -30,6 +30,12 @@ struct BadgesView: View { // holds the purchase screens' slot, so a consumable cannot be bought twice BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet) .transition(.opacity) + .onReceive(store.refusals) { refusal in + // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own + if purchaseState == .issuing { + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(refusal, purchase: true)) + } + } } else if store.checkingPurchases { BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet) .transition(.opacity) 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 881ee9c1f9..5d02b75add 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 @@ -6,12 +6,10 @@ import androidx.compose.runtime.mutableStateOf import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* -import chat.simplex.common.views.helpers.AlertManager -import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.withContext import java.text.NumberFormat import java.util.Currency @@ -159,8 +157,7 @@ object BadgeStore { // set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while // the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses private val reconciledOnce = mutableStateOf(false) - // purchases the user started this run, by token: the store is not relied on to echo the invoice back - private val awaitedTransactions = mutableSetOf() + val refusals = MutableSharedFlow() fun purchaseState(userId: Long?): BadgePurchaseState? { if (!badgeStoreAvailable) return null @@ -246,7 +243,6 @@ object BadgeStore { try { val outcome = storePurchase(id, invoiceId) if (outcome is BadgePurchaseOutcome.Purchased) { - withContext(Dispatchers.Main) { awaitedTransactions += outcome.receipt.token } handOver(outcome.receipt) } return outcome @@ -312,9 +308,8 @@ object BadgeStore { private suspend fun settle(receipt: BadgeStoreReceipt, refusal: ChatError?) { finish(receipt) - val awaited = withContext(Dispatchers.Main) { awaitedTransactions.remove(receipt.token) } - if (awaited && refusal != null) { - AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = chatModel.controller.redeemErrorText(refusal, purchase = true)) + if (refusal != null) { + withContext(Dispatchers.Main) { refusals.emit(refusal) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index 799bf4c673..d631735e3e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -7,8 +7,10 @@ import androidx.compose.runtime.remember import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR @OptIn(ExperimentalAnimationApi::class) @@ -29,6 +31,14 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) { } else if (purchaseState != null) { // holds the purchase screens' slot, so a consumable cannot be bought twice BadgesPurchaseStateView(purchaseState.title, purchaseState.message, BadgeStore.creditError(chatModel.currentUser.value?.userId), onDismiss = close) + LaunchedEffect(purchaseState) { + BadgeStore.refusals.collect { refusal -> + // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own + if (purchaseState == BadgePurchaseState.Issuing) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = chatModel.controller.redeemErrorText(refusal, purchase = true)) + } + } + } } else if (checkingPurchases) { BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, null, onDismiss = close) } else {