ios, android: tell whoever is looking when a purchase is refused

This commit is contained in:
spaced4ndy
2026-10-06 18:07:06 +04:00
parent 258eb1c306
commit f636d2d3f5
4 changed files with 23 additions and 17 deletions
@@ -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<UInt64> = []
let refusals = PassthroughSubject<Error, Never>()
private var transactionUpdates: Task<Void, Never>? = 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<Transaction>) -> Ba
return BadgeStoreReceipt(
jws: verification.jwsRepresentation,
productId: t.productID,
transactionId: t.id,
invoiceId: t.appAccountToken,
signatureVerified: signatureVerified,
transaction: t
@@ -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)
@@ -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<String>()
val refusals = MutableSharedFlow<ChatError>()
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) }
}
}
@@ -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 {