core, ios, android: name the resolved store purchase so the app finishes it without a sweep (#7659)

This commit is contained in:
spaced4ndy authored and GitHub committed 2026-10-08 13:46:27 +00:00
1 parent c0c5ba8393
commit b4f1249436
18 files changed
+174 -129

No files matched your search

+6 -3
View File
@@ -1283,7 +1283,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
// badges
case badgeChanged(user: User, badgeState: BadgeState?)
case badgeAlert(user: UserRef, badgeAlert: BadgeAlert)
case storePurchaseResolved(user: UserRef)
case storePurchaseCredited(user: UserRef, invoiceId: String)
case storePurchaseRefused(user: UserRef, invoiceId: String, refusal: BadgeIssueFailure)
var responseType: String {
switch self {
@@ -1358,7 +1359,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .contactPQEnabled: "contactPQEnabled"
case .badgeChanged: "badgeChanged"
case .badgeAlert: "badgeAlert"
case .storePurchaseResolved: "storePurchaseResolved"
case .storePurchaseCredited: "storePurchaseCredited"
case .storePurchaseRefused: "storePurchaseRefused"
}
}
@@ -1443,7 +1445,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)")
case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState))
case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert))
case .storePurchaseResolved: return noDetails
case let .storePurchaseCredited(u, invoiceId): return withUser(u, "invoiceId: \(invoiceId)")
case let .storePurchaseRefused(u, invoiceId, refusal): return withUser(u, "invoiceId: \(invoiceId)\nrefusal: \(refusal.tag)")
}
}
}
+4 -3
View File
@@ -3094,9 +3094,10 @@ func processReceivedMsg(_ res: ChatEvent) async {
BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert)
}
}
case .storePurchaseResolved:
// whichever profile owns it: only the app can finish the store transaction
Task { await BadgeStore.shared.presentUnfinished() }
case let .storePurchaseCredited(user, invoiceId):
Task { await BadgeStore.shared.storePurchaseResolved(user, invoiceId) }
case let .storePurchaseRefused(user, invoiceId, refusal):
Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) }
default:
logger.debug("unsupported event: \(res.responseType)")
}
+29 -10
View File
@@ -111,7 +111,6 @@ 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
let refusals = PassthroughSubject<Error, Never>()
private var transactionUpdates: Task<Void, Never>? = nil
private init() {}
@@ -233,7 +232,7 @@ final class BadgeStore: ObservableObject {
// or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way.
private func handOver(_ receipt: BadgeStoreReceipt) async throws {
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return }
// a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer
// a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer
do {
switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) {
case let .held(user, badgeState, storePurchases):
@@ -252,22 +251,41 @@ final class BadgeStore: ObservableObject {
if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) }
}
}
await resolve(receipt, refusal: nil)
await receipt.transaction.finish()
}
} catch let error where badgeReceiptRefused(error) {
logger.error("BadgeStore.handOver: \(responseError(error))")
await resolve(receipt, refusal: error)
await receipt.transaction.finish()
}
}
private func resolve(_ receipt: BadgeStoreReceipt, refusal: Error?) async {
await receipt.transaction.finish()
if let refusal {
await MainActor.run { refusals.send(refusal) }
func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async {
await MainActor.run {
if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) {
showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: refusal.purchaseText)
}
}
await storePurchaseResolved(user, invoiceId)
}
func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async {
// the row goes before the store is read, or the screen shows it open while the store answers;
// the transaction is finished whoever owns it, as only the app can, while the cached row is the active profile's
await MainActor.run {
if active(user), let open = storePurchases, open.userId == user.userId {
storePurchases = (open.userId, open.purchases.filter { $0.invoiceId != invoiceId })
}
}
for await verification in Transaction.unfinished {
let receipt = storeReceipt(verification)
if receipt.echoedInvoiceId == invoiceId {
await receipt.transaction.finish()
return
}
}
}
// at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer
// at launch, on return to the foreground and on a profile switch, never on a timer
func presentUnfinished() async {
await listenForTransactions()
for await verification in Transaction.unfinished {
@@ -339,7 +357,8 @@ private func storeReceipt(_ verification: VerificationResult<Transaction>) -> Ba
)
}
// the codes core refuses a receipt with for good, after which the store may stop re-delivering it
// the codes core refuses a receipt with for good, after which the store may stop re-delivering it;
// a sweep that hands over a receipt refused while the app was not running learns it only from this answer
private func badgeReceiptRefused(_ error: Error) -> Bool {
if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError {
return code == .receiptInvalid || code == .receiptUsed
@@ -30,12 +30,6 @@ 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)
@@ -14,7 +14,6 @@ struct BadgesYourBadgeView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) private var dismiss
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@ObservedObject private var store = BadgeStore.shared
let badgeState: BadgeState
var showsAsSheet: Bool = false
@@ -79,15 +78,6 @@ struct BadgesYourBadgeView: View {
}
}
}
if store.purchaseState(chatModel.currentUser?.userId) != nil {
Section {
Text("A store purchase is being processed.")
.foregroundColor(theme.colors.secondary)
} header: {
Text("Store purchase")
.foregroundColor(theme.colors.secondary)
}
}
if developerTools {
Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) {
if let badge = chatModel.currentUser?.profile.localBadge {
@@ -3612,9 +3612,10 @@ object ChatController {
BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert)
}
}
is CR.StorePurchaseResolved ->
// whichever profile owns it: only the app can finish the store purchase
withLongRunningApi { BadgeStore.presentUnfinished() }
is CR.StorePurchaseCredited ->
withLongRunningApi { BadgeStore.storePurchaseResolved(rhId, r.user, r.invoiceId) }
is CR.StorePurchaseRefused ->
withLongRunningApi { BadgeStore.storePurchaseRefused(rhId, r.user, r.invoiceId, r.refusal) }
else ->
Log.d(TAG , "unsupported event: ${msg.responseType}")
}
@@ -6963,7 +6964,8 @@ sealed class CR {
@Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List<StatementEntry>): CR()
@Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR()
@Serializable @SerialName("storePurchaseResolved") class StorePurchaseResolved(val user: UserRef): CR()
@Serializable @SerialName("storePurchaseCredited") class StorePurchaseCredited(val user: UserRef, val invoiceId: String): CR()
@Serializable @SerialName("storePurchaseRefused") class StorePurchaseRefused(val user: UserRef, val invoiceId: String, val refusal: BadgeIssueFailure): CR()
// general
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -7156,7 +7158,8 @@ sealed class CR {
is BadgeLedger -> "badgeLedger"
is BadgeChanged -> "badgeChanged"
is BadgeAlertR -> "badgeAlert"
is StorePurchaseResolved -> "storePurchaseResolved"
is StorePurchaseCredited -> "storePurchaseCredited"
is StorePurchaseRefused -> "storePurchaseRefused"
is Response -> "* $type"
is Invalid -> "* invalid json"
}
@@ -7366,7 +7369,8 @@ sealed class CR {
is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger))
is BadgeChanged -> withUser(user, json.encodeToString(badgeState))
is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert))
is StorePurchaseResolved -> withUser(user, noDetails())
is StorePurchaseCredited -> withUser(user, "invoiceId: $invoiceId")
is StorePurchaseRefused -> withUser(user, "invoiceId: $invoiceId\nrefusal: ${refusal.tag}")
is Response -> json
is Invalid -> str
}
@@ -6,10 +6,12 @@ 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.*
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
@@ -157,7 +159,6 @@ 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)
val refusals = MutableSharedFlow<ChatError>()
fun purchaseState(userId: Long?): BadgePurchaseState? {
if (!badgeStoreAvailable) return null
@@ -292,27 +293,50 @@ object BadgeStore {
if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true)
}
}
resolve(receipt, refusal = null)
finish(receipt)
}
// a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer
// a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer
is BadgePurchaseResult.Failed ->
if (badgeReceiptRefused(r.err)) {
Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}")
resolve(receipt, refusal = r.err)
finish(receipt)
} else {
throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response")
}
}
}
private suspend fun resolve(receipt: BadgeStoreReceipt, refusal: ChatError?) {
finish(receipt)
if (refusal != null) {
withContext(Dispatchers.Main) { refusals.emit(refusal) }
suspend fun storePurchaseRefused(rhId: Long?, user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) {
withContext(Dispatchers.Main) {
if (chatModel.controller.activeUser(rhId, user) && openStorePurchases(user.userId).any { it.invoiceId == invoiceId }) {
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = refusal.purchaseText)
}
}
storePurchaseResolved(rhId, user, invoiceId)
}
suspend fun storePurchaseResolved(rhId: Long?, user: UserRef, invoiceId: String) {
// the row goes before the store is read, or the screen shows it open while the store answers;
// the purchase is finished whoever owns it, as only the app can, while the cached row is the active profile's
withContext(Dispatchers.Main) {
val (readOn, readFor, purchases) = storePurchases.value ?: return@withContext
if (chatModel.controller.activeUser(rhId, user) && readOn == rhId && readFor == user.userId) {
storePurchases.value = Triple(readOn, readFor, purchases.filter { it.invoiceId != invoiceId })
}
}
if (rhId != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return
try {
platform.androidUnfinishedBadgePurchases()
.firstNotNullOfOrNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.takeIf { r -> r.invoiceId == invoiceId } }
?.let { finish(it) }
} catch (e: Exception) {
if (e is CancellationException) throw e
// the purchase stays unfinished, and the next sweep hands it over and finishes it
Log.e(TAG, "BadgeStore.storePurchaseResolved: ${e.message}")
}
}
// at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer
// at launch, on return to the foreground and on a profile switch, never on a timer
suspend fun presentUnfinished() {
try {
if (!useBadgeTestProducts && platform.androidHasPlatformStore) {
@@ -390,7 +414,8 @@ private fun compactPrice(product: BadgeProduct): String {
}
}
// the codes core refuses a receipt with for good, after which the store may stop re-delivering it
// the codes core refuses a receipt with for good, after which the store may stop re-delivering it;
// a sweep that hands over a receipt refused while the app was not running learns it only from this answer
private fun badgeReceiptRefused(err: ChatError?): Boolean {
val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError
val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError
@@ -17,7 +17,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingActionButton
import chat.simplex.res.MR
import chat.simplex.res.*
@Composable
fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) {
@@ -16,7 +16,7 @@ import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.BadgeIssueFailure
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton
import chat.simplex.res.MR
import chat.simplex.res.*
@Composable
fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) {
@@ -7,11 +7,9 @@ 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
import chat.simplex.res.*
@OptIn(ExperimentalAnimationApi::class)
@Composable
@@ -31,14 +29,6 @@ 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 {
@@ -78,14 +78,6 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) {
}
SectionSpacer()
}
if (BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null) {
SectionView(stringResource(MR.strings.badges_store_purchase_title)) {
SectionItemView {
Text(stringResource(MR.strings.badges_store_purchase_processing), color = MaterialTheme.colors.secondary)
}
}
SectionSpacer()
}
if (appPrefs.developerTools.get()) {
val clipboard = LocalClipboardManager.current
SectionView(stringResource(MR.strings.badges_credential)) {
@@ -3196,8 +3196,6 @@
<string name="badges_waiting_for_approval_title">Waiting for approval</string>
<string name="badges_waiting_for_approval_body">Nothing has been charged.</string>
<string name="badges_checking_purchases_title">Checking your purchases</string>
<string name="badges_store_purchase_title">Store purchase</string>
<string name="badges_store_purchase_processing">A store purchase is being processed.</string>
<string name="badges_buy_in_browser">Buy in browser</string>
<string name="badges_redeem_code_title">Redeem code</string>
<string name="badges_link_confirm_title">Add badge to your profile?</string>