From e80e885aacb4685f94096beadd2955aa46add007 Mon Sep 17 00:00:00 2001
From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:28:12 +0400
Subject: [PATCH] ios, android: catch a failed purchase presentation, word
store purchase errors, and sweep abandoned purchases on a profile switch
---
apps/ios/Shared/Model/SimpleXAPI.swift | 2 ++
apps/ios/Shared/Views/Badges/BadgeStore.swift | 11 ++++++++++-
apps/ios/SimpleXChat/APITypes.swift | 7 +++++--
.../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 5 +++++
.../chat/simplex/common/views/badges/BadgeStore.kt | 14 +++++++++++++-
.../src/commonMain/resources/MR/base/strings.xml | 4 ++++
6 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 49a0b5c0a6..b5767f2348 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -2425,6 +2425,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd)
m.users = try listUsers()
try getUserChatData()
+ Task { await BadgeStore.shared.presentUnfinished() }
}
func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws {
@@ -2440,6 +2441,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: S
m.users = users
}
try await getUserChatDataAsync(keepingChatId: keepingChatId)
+ Task { await BadgeStore.shared.presentUnfinished() }
await MainActor.run {
if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
invitation.user = currentUser
diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift
index b2566b2c7e..5346f8e914 100644
--- a/apps/ios/Shared/Views/Badges/BadgeStore.swift
+++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift
@@ -259,7 +259,7 @@ final class BadgeStore: ObservableObject {
let refused = badgeReceiptRefused(error)
if refused { await finish(receipt) }
let text = redeemErrorText(error, purchase: true)
- alertText = refused ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message")
+ alertText = refused || retryCannotCredit(error) ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message")
}
await loadCurrentBadgeState()
let userWaiting = await MainActor.run { presenting.removeValue(forKey: receipt.transactionId) == true }
@@ -393,3 +393,12 @@ private func badgeReceiptRefused(_ error: Error) -> Bool {
}
return false
}
+
+private func retryCannotCredit(_ error: Error) -> Bool {
+ guard case let .error(.badgeRedeemError(e)) = error as? ChatError else { return false }
+ switch e {
+ case .badgeActive, .serviceNotConfigured: return true
+ case let .serviceError(code): return code == .providerNotConfigured || code == .productUnavailable
+ default: return false
+ }
+}
diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift
index acc4939cf8..1815a53f4f 100644
--- a/apps/ios/SimpleXChat/APITypes.swift
+++ b/apps/ios/SimpleXChat/APITypes.swift
@@ -917,8 +917,11 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? {
case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message")
case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message")
case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message")
- case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable,
- .paymentNotEntitled, .paymentPending, .providerUnavailable, .providerNotConfigured, .unknown: nil
+ case .paymentPending: NSLocalizedString("The payment has not been confirmed yet.", comment: "alert message")
+ case .providerUnavailable: NSLocalizedString("The store could not be reached.", comment: "alert message")
+ case .providerNotConfigured: NSLocalizedString("Badges cannot be bought in the app yet.", comment: "alert message")
+ case .productUnavailable: NSLocalizedString("This badge is not on sale.", comment: "alert message")
+ case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .paymentNotEntitled, .unknown: nil
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
index 7594547128..3a224f8d0c 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
@@ -789,6 +789,7 @@ object ChatController {
chatModel.users.clear()
chatModel.users.addAll(users)
getUserChatData(rhId, keepingChatId = keepingChatId)
+ withLongRunningApi { BadgeStore.presentUnfinished() }
val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId }
if (invitation != null && currentUser != null) {
chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser))
@@ -7468,6 +7469,10 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) {
is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal)
is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid)
is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used)
+ is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending)
+ is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable)
+ is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured)
+ is BadgeServiceErrorCode.ProductUnavailable -> generalGetString(MR.strings.badges_error_product_unavailable)
else -> null
}
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 dc6ad6f55d..2458d119b2 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
@@ -9,6 +9,7 @@ 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.withContext
@@ -299,10 +300,14 @@ object BadgeStore {
val refused = badgeReceiptRefused(r.err)
if (refused) finish(receipt)
val text = chatModel.controller.redeemErrorText(r.err, purchase = true)
- alertText = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
+ alertText = if (refused || retryCannotCredit(r.err)) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
}
null -> {}
}
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.e(TAG, "BadgeStore.presentPurchase: ${e.stackTraceToString()}")
+ alertText = "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
} finally {
withContext(NonCancellable) { chatModel.controller.loadBadgeState(chatModel.remoteHostId()) }
userWaiting = withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) == true }
@@ -419,3 +424,10 @@ private fun badgeReceiptRefused(err: ChatError?): Boolean {
val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError
return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed
}
+
+private fun retryCannotCredit(err: ChatError?): Boolean =
+ when (val e = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError) {
+ is BadgeRedeemError.BadgeActive, is BadgeRedeemError.ServiceNotConfigured -> true
+ is BadgeRedeemError.ServiceError -> e.serviceError is BadgeServiceErrorCode.ProviderNotConfigured || e.serviceError is BadgeServiceErrorCode.ProductUnavailable
+ else -> false
+ }
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index ed4bf596d2..636fa4845c 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -3225,6 +3225,10 @@
The badge service reported an internal error.
The store did not confirm this purchase.
This purchase was already credited to a badge on another device or profile.
+ The payment has not been confirmed yet.
+ The store could not be reached.
+ Badges cannot be bought in the app yet.
+ This badge is not on sale.
The code was accepted, but the badge it grants has already ended.
The code could not be redeemed.
The code you scanned is not a badge code.