From 110aa53c356c208e90dc4e3cf882b77e2e893be0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:34:17 +0400 Subject: [PATCH] ios, android: hand store receipts to core, finish what it settles, and show why a held purchase is not credited yet --- apps/ios/Shared/Model/AppAPITypes.swift | 4 + apps/ios/Shared/Model/SimpleXAPI.swift | 23 ++- apps/ios/Shared/Views/Badges/BadgeStore.swift | 111 ++++++--------- .../Badges/BadgesPurchaseStateView.swift | 14 ++ apps/ios/Shared/Views/Badges/BadgesView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 10 ++ .../foss/java/chat/simplex/app/PlayStore.kt | 3 + .../google/java/chat/simplex/app/PlayStore.kt | 25 +++- .../main/java/chat/simplex/app/SimplexApp.kt | 2 + .../chat/simplex/common/model/ChatModel.kt | 6 + .../chat/simplex/common/model/SimpleXAPI.kt | 21 ++- .../chat/simplex/common/platform/Platform.kt | 1 + .../simplex/common/views/badges/BadgeStore.kt | 131 +++++++++--------- .../views/badges/BadgesPurchaseStateView.kt | 18 ++- .../simplex/common/views/badges/BadgesView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 2 +- 16 files changed, 208 insertions(+), 167 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 7bc4d2d4c7..f70f15066b 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -756,6 +756,7 @@ enum ServicePayment: Encodable { struct OpenStorePurchase: Decodable { var invoiceId: String? var transactionRef: String? + var creditError: BadgeIssueFailure? } // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. @@ -1290,6 +1291,7 @@ enum ChatEvent: Decodable, ChatAPIResult { // badges case badgeChanged(user: User, badgeState: BadgeState?) case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) + case storePurchaseSettled(user: UserRef) var responseType: String { switch self { @@ -1364,6 +1366,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .contactPQEnabled: "contactPQEnabled" case .badgeChanged: "badgeChanged" case .badgeAlert: "badgeAlert" + case .storePurchaseSettled: "storePurchaseSettled" } } @@ -1448,6 +1451,7 @@ 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 .storePurchaseSettled: return noDetails } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ed7bae201d..591bfee9dd 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2196,24 +2196,18 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: throw r.unexpected } +// a refusal is thrown, as BREServiceError with the code enum BadgePurchaseResult { - case redeemed(user: User, badgeState: BadgeState?) + case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) + case credited(user: User, badgeState: BadgeState?) } // log: false because a store receipt is a bearer secret, like a badge code - it is in the command. -// nil when the user cancels the retry alert, which is offered only when retry is set. -func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment, retry: Bool) async throws -> BadgePurchaseResult? { - let cmd = ChatCommand.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment) - let r: APIResult? - if retry { - r = await chatApiSendCmdWithRetry(cmd, log: false) - } else { - let res: APIResult = await chatApiSendCmd(cmd, log: false) - r = res - } - guard let r else { return nil } +func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment) async throws -> BadgePurchaseResult { + let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment), log: false) switch r { - case let .result(.badgeRedeemed(user, _, _, badgeState)): return .redeemed(user: user, badgeState: badgeState) + case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? []) + case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState) default: throw r.unexpected } } @@ -3100,6 +3094,9 @@ func processReceivedMsg(_ res: ChatEvent) async { BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert) } } + case .storePurchaseSettled: + // whichever profile owns it: only the app can finish the store transaction + Task { await BadgeStore.shared.presentUnfinished() } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 398f4d3f77..d2a1a302fe 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 @@ -102,33 +101,33 @@ final class BadgeStore: ObservableObject { @Published private var state: LoadState = .notLoaded private var products: [String: Product] = [:] - // transactions this run has started presenting and not finished - filled by claim, not by reading the - // store, so it is empty at launch until the sweep reaches each one - @Published private var unfinished: [UInt64: BadgeStoreReceipt] = [:] // core's open store purchases for the profile they were read for: core knows whose a purchase is @Published private var storePurchases: (userId: Int64, purchases: [OpenStorePurchase])? = nil - // whether this run has an open store sheet, or an interactive presentation that has not returned + // whether a store sheet this run opened has not returned @Published private var buying = false // kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing // by invoice id, so a pending purchase shows only under the profile whose record it names @Published private var waitingForApproval: Set = [] - // set when the first sweep has returned, which is after every held transaction has been presented - // over the network - until then a purchase made while the app was not running is unknown + // set when the first sweep has returned, which is after every transaction the store holds was handed + // to core - until then a purchase made while the app was not running is unknown @Published private var reconciledOnce = false - // whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it - private var presenting: [UInt64: Bool] = [:] + // invoices of the purchases the user started this run, whose refusal is theirs to be told of + private var awaitedInvoices: Set = [] private var transactionUpdates: Task? = nil private init() {} func purchaseState(_ userId: Int64?) -> BadgePurchaseState? { let purchases = openStorePurchases(userId) - let held = Set(unfinished.values.compactMap { $0.echoedInvoiceId }) - if purchases.contains(where: { $0.invoiceId.map(held.contains) == true }) { return .issuing } + if purchases.contains(where: { $0.transactionRef != nil }) { return .issuing } if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval } return nil } + func creditError(_ userId: Int64?) -> BadgeIssueFailure? { + openStorePurchases(userId).compactMap(\.creditError).first + } + var checkingPurchases: Bool { !reconciledOnce } func canBuy(_ userId: Int64?) -> Bool { @@ -208,10 +207,9 @@ final class BadgeStore: ObservableObject { await loadBadgeStateAsync(userId) do { let outcome = try await storePurchase(product, invoiceId) - switch outcome { - // finished only once the service answers for it, as an unfinished transaction is what the store re-delivers - case let .purchased(receipt) where receipt.signatureVerified: await presentPurchase(receipt, interactive: true) - case .purchased, .cancelled, .pending: break + if case let .purchased(receipt) = outcome, receipt.signatureVerified { + await MainActor.run { _ = awaitedInvoices.insert(invoice) } + await handOver(receipt) } await MainActor.run { buying = false } return outcome @@ -233,47 +231,51 @@ final class BadgeStore: ObservableObject { } } - // only the purchase the user started may alert: an answer can reveal a profile other than the one on screen - private func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async { - guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }), - await claim(receipt, interactive: interactive) - else { return } - var alertText: String? = nil + // Core holds the receipt and credits it; the transaction stays unfinished until core answers it credited + // 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 { + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } do { - switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws), retry: interactive) { - case let .redeemed(user, badgeState): + switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) { + case let .held(user, badgeState, storePurchases): + await MainActor.run { + // the answer is the owner's, which may be another profile and a hidden one + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + setStorePurchases(user.userId, storePurchases) + } + } + case let .credited(user, badgeState): await MainActor.run { - // finished below whichever profile was credited, as it is paid for; only the profile on screen shows it if active(user) { BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) ChatModel.shared.updateUser(user) if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) } } } - await finish(receipt) - case nil: - break + await settle(receipt, refusal: nil) } } catch let error { - logger.error("BadgeStore.presentPurchase: \(responseError(error))") - let refused = badgeReceiptRefused(error) - if refused { await finish(receipt) } - let text = redeemErrorText(error, purchase: true) - 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 } - if userWaiting, let alertText { - await MainActor.run { showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: alertText) } + logger.error("BadgeStore.handOver: \(responseError(error))") + if badgeReceiptRefused(error) { await settle(receipt, refusal: error) } } } - // at launch, on return to the foreground and on a profile switch, never on a timer + private func settle(_ receipt: BadgeStoreReceipt, refusal: Error?) async { + await receipt.transaction.finish() + let awaited = await MainActor.run { receipt.echoedInvoiceId.map { awaitedInvoices.remove($0) != nil } ?? false } + if awaited, let refusal { + await MainActor.run { showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(refusal, purchase: true)) } + } + } + + // at launch, on return to the foreground, on a profile switch and when core settles a purchase, never on a timer func presentUnfinished() async { await listenForTransactions() for await verification in Transaction.unfinished { await reconcile(verification) } + await loadCurrentBadgeState() await MainActor.run { reconciledOnce = true } } @@ -295,29 +297,10 @@ final class BadgeStore: ObservableObject { if !badgeOneTimeProductIds.contains(receipt.productId) { await receipt.transaction.finish() } else if receipt.signatureVerified { - await presentPurchase(receipt, interactive: false) + await handOver(receipt) } } - // one request per transaction: the purchase itself, launch, foreground and the store can each present it - @MainActor - private func claim(_ receipt: BadgeStoreReceipt, interactive: Bool) -> Bool { - if let waiting = presenting[receipt.transactionId] { - presenting[receipt.transactionId] = waiting || interactive - return false - } - presenting[receipt.transactionId] = interactive - unfinished[receipt.transactionId] = receipt - // an approved Ask to Buy arrives as a transaction, which ends the wait - if let invoiceId = receipt.echoedInvoiceId { waitingForApproval.remove(invoiceId) } - return true - } - - private func finish(_ receipt: BadgeStoreReceipt) async { - await receipt.transaction.finish() - await MainActor.run { unfinished[receipt.transactionId] = nil } - } - @MainActor private func startLoading() -> Bool { switch state { @@ -349,26 +332,16 @@ private func storeReceipt(_ verification: VerificationResult) -> Ba return BadgeStoreReceipt( jws: verification.jwsRepresentation, productId: t.productID, - transactionId: t.id, invoiceId: t.appAccountToken, signatureVerified: signatureVerified, transaction: t ) } -// the only answers after which the receipt will never be credited, so 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 private func badgeReceiptRefused(_ error: Error) -> Bool { if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { return code == .receiptInvalid || code == .receiptUsed } 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/Shared/Views/Badges/BadgesPurchaseStateView.swift b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift index 710a4ed4fa..702304acd4 100644 --- a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift @@ -7,12 +7,14 @@ // import SwiftUI +import SimpleXChat struct BadgesPurchaseStateView: View { @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) private var dismiss let title: LocalizedStringKey var message: LocalizedStringKey? = nil + var failure: BadgeIssueFailure? = nil var showsAsSheet: Bool = false var body: some View { @@ -45,6 +47,18 @@ struct BadgesPurchaseStateView: View { .fixedSize(horizontal: false, vertical: true) } + if let failure { + VStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text(failure.purchaseText) + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer() ProgressView().scaleEffect(2) diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index 4de8383094..2d37860c45 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -28,7 +28,7 @@ struct BadgesView: View { .transition(.opacity) } else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) { // holds the purchase screens' slot, so a consumable cannot be bought twice - BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, showsAsSheet: showsAsSheet) + BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet) .transition(.opacity) } else if store.checkingPurchases { BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index cf520a65c7..9e6ff84b96 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -368,6 +368,16 @@ public enum BadgeIssueFailure: Decodable, Hashable { } } + public var purchaseText: String { + switch self { + case let .serviceError(code, _): + badgeServiceErrorText(code) + ?? String.localizedStringWithFormat(NSLocalizedString("The badge service refused the purchase: %@", comment: "badge purchase error"), code.text) + case .invalidCredential: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") + case .serviceTimeout, .network, .unexpected: text + } + } + // the stored form, for support public var tag: String { switch self { diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index b93b0b428d..0cad378da6 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -24,6 +24,9 @@ suspend fun loadBadgeProducts(oneTimeIds: List, subscriptio suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable +@Suppress("UNUSED_PARAMETER") +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} + @Suppress("UNUSED_PARAMETER") suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {} 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 4b332e01a3..81e30d8921 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 @@ -143,7 +143,8 @@ private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = wh BadgeStoreReceipt( token = purchase.purchaseToken, productId = purchase.products.firstOrNull() ?: "", - invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId, + acknowledged = purchase.isAcknowledged ) ) else -> null @@ -225,19 +226,33 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { return BadgeOffer(id, product, this, offer.offerToken) } +// acknowledged without consuming, so Play stops its 3-day refund clock and still lists the purchase until it is finished +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) { + if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token) +} + // consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase // after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) { val client = connectedBadgeBillingClient() - val done = CompletableDeferred() if (receipt.productId in badgeOneTimeProductIds) { + val done = CompletableDeferred() val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() client.consumeAsync(params) { result, _ -> done.complete(result) } + checkBillingResult(done.await()) } else { - val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() - client.acknowledgePurchase(params) { done.complete(it) } + acknowledge(client, receipt.token) } - val result = done.await() +} + +private suspend fun acknowledge(client: BillingClient, token: String) { + val done = CompletableDeferred() + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build() + client.acknowledgePurchase(params) { done.complete(it) } + checkBillingResult(done.await()) +} + +private fun checkBillingResult(result: BillingResult) { if (result.responseCode != BillingClient.BillingResponseCode.OK) { throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1e2a802206..9093b1427b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -355,6 +355,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + override suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) = acknowledgeBadgePurchase(receipt) + override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt) override suspend fun androidUnfinishedBadgePurchases(): List = unfinishedBadgePurchases() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 9d65606a9c..6a345bb612 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2271,6 +2271,12 @@ sealed class BadgeIssueFailure { is Unexpected -> String.format(generalGetString(MR.strings.badges_error_unexpected), message) } + val purchaseText: String get() = when (this) { + is ServiceError -> badgeServiceErrorText(code) ?: String.format(generalGetString(MR.strings.badges_error_purchase_refused), code.text) + is InvalidCredential -> generalGetString(MR.strings.badges_error_credential_not_verified) + is ServiceTimeout, is Network, is Unexpected -> text + } + // the stored form, for support val tag: String get() = when (this) { is ServiceError -> "serviceError ${if (retryable) "retry" else "final"} ${code.text}" 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 eeac652c94..7ff908b7c1 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 @@ -590,12 +590,11 @@ object ChatController { } // log = false because a store receipt is a bearer secret, like a badge code - it is in the command. - // null when the user cancels the retry alert, which is offered only when retry is set. - suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment, retry: Boolean): BadgePurchaseResult? { - val cmd = CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment) - val r = (if (retry) sendCmdWithRetry(rh, cmd, log = false) else sendCmd(rh, cmd, log = false)) ?: return null + suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult { + val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false) return when { - r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Redeemed(r.res.user.updateRemoteHostId(rh), r.res.badgeState) + r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases) + r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(r.res.user.updateRemoteHostId(rh), r.res.badgeState) r is API.Error -> BadgePurchaseResult.Failed(r.err) else -> { // the response type alone - it names a case or a JSON key, never the service's message @@ -3614,6 +3613,9 @@ object ChatController { BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) } } + is CR.StorePurchaseSettled -> + // whichever profile owns it: only the app can finish the store purchase + withLongRunningApi { BadgeStore.presentUnfinished() } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -3918,10 +3920,12 @@ sealed class BadgeRedeemResult { } @Serializable -data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null) +data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null, val creditError: BadgeIssueFailure? = null) +// a refusal is Failed, with the code in BREServiceError sealed class BadgePurchaseResult { - class Redeemed(val user: User, val badgeState: BadgeState?): BadgePurchaseResult() + class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List): BadgePurchaseResult() + class Credited(val user: User, val badgeState: BadgeState?): BadgePurchaseResult() // err is null for a response of an unexpected type, which is logged where it is received class Failed(val err: ChatError?): BadgePurchaseResult() } @@ -6929,6 +6933,7 @@ sealed class CR { @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): 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("storePurchaseSettled") class StorePurchaseSettled(val user: UserRef): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -7121,6 +7126,7 @@ sealed class CR { is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" + is StorePurchaseSettled -> "storePurchaseSettled" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7330,6 +7336,7 @@ 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 StorePurchaseSettled -> withUser(user, noDetails()) is Response -> json is Invalid -> str } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 249c07913b..f4e902b967 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -45,6 +45,7 @@ interface PlatformInterface { return emptyList() } suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun androidUnfinishedBadgePurchases(): List = emptyList() val androidApiLevel: Int? get() = 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 8f4b0da27b..0ee09547bd 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 @@ -92,7 +92,8 @@ data class BadgeStoreReceipt( // the token the badge service verifies with the Publisher API val token: String, val productId: String, - val invoiceId: String? + val invoiceId: String?, + val acknowledged: Boolean = false ) // TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens @@ -149,33 +150,32 @@ object BadgeStore { private val state = mutableStateOf(LoadState.NotLoaded) // snapshot state so a composable reading only the products still recomposes when they arrive private val products = mutableStateOf>(emptyMap()) - // purchases this run has started presenting and not finished - filled by claim, not by reading the - // store, so it is empty at launch until the sweep reaches each one - private val unfinished = mutableStateOf>(emptyMap()) // core's open store purchases for the profile they were read for: core knows whose a purchase is private val storePurchases = mutableStateOf>?>(null) - // whether this run has an open store sheet, or an interactive presentation that has not returned + // whether a store sheet this run opened has not returned private val buying = mutableStateOf(false) // by invoice id, so a pending purchase shows only under the profile whose record it names private val waitingForApproval = mutableStateOf>(emptySet()) - // set when the first sweep has returned or failed, which is after every held purchase has been presented - // over the network - until then a purchase made while the app was not running is unknown + // set when the first sweep has returned or failed, which is after every purchase the store holds was handed + // to core - until then a purchase made while the app was not running is unknown private val reconciledOnce = mutableStateOf(false) - // whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it; + // invoices of the purchases the user started this run, whose refusal is theirs to be told of; // read and written on the main thread only - private val presenting = mutableMapOf() + private val awaitedInvoices = mutableSetOf() fun purchaseState(userId: Long?): BadgePurchaseState? { if (!badgeStoreAvailable) return null val purchases = openStorePurchases(userId) - val held = unfinished.value.values.mapNotNull { it.invoiceId }.toSet() return when { - purchases.any { it.invoiceId?.let(held::contains) == true } -> BadgePurchaseState.Issuing + purchases.any { it.transactionRef != null } -> BadgePurchaseState.Issuing purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval else -> null } } + fun creditError(userId: Long?): BadgeIssueFailure? = + openStorePurchases(userId).firstNotNullOfOrNull { it.creditError } + val checkingPurchases: Boolean get() = badgeStoreAvailable && !reconciledOnce.value fun canBuy(userId: Long?): Boolean = @@ -246,10 +246,9 @@ object BadgeStore { chatModel.controller.loadBadgeState(rhId) try { val outcome = storePurchase(id, invoiceId) - when (outcome) { - // finished only once the service answers for it, as an unfinished purchase is what the store re-delivers - is BadgePurchaseOutcome.Purchased -> presentPurchase(outcome.receipt, interactive = true) - is BadgePurchaseOutcome.Cancelled, is BadgePurchaseOutcome.Pending -> {} + if (outcome is BadgePurchaseOutcome.Purchased) { + withContext(Dispatchers.Main) { awaitedInvoices += invoiceId } + handOver(outcome.receipt) } return outcome } finally { @@ -273,61 +272,68 @@ object BadgeStore { return outcome } - // only the purchase the user started may alert: an answer can reveal a profile other than the one on screen - private suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) { + // Core holds the receipt and credits it; the purchase stays unfinished until core answers it credited + // or refused, as an unfinished purchase is what the store re-delivers if anything is lost on the way. + private suspend fun handOver(receipt: BadgeStoreReceipt) { val rhId = chatModel.remoteHostId() val userId = chatModel.currentUser.value?.userId ?: return - if (!claim(receipt, interactive)) return - var alertText: String? = null - var userWaiting = false try { - when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token), retry = interactive)) { - is BadgePurchaseResult.Redeemed -> { + when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token))) { + is BadgePurchaseResult.Held -> { + // core holds it durably now, so Play must not refund it after three days unacknowledged + acknowledge(receipt) + withContext(Dispatchers.Main) { + // the answer is the owner's, which may be another profile and a hidden one + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + setStorePurchases(rhId, r.user.userId, r.storePurchases) + } + } + } + is BadgePurchaseResult.Credited -> { withContext(Dispatchers.Main) { - // finished below whichever profile was credited, as it is paid for; only the profile on screen shows it if (chatModel.controller.activeUser(rhId, r.user)) { BadgeModel.set(rhId, r.user.userId, r.badgeState) chatModel.updateUser(r.user) if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true) } } - finish(receipt) + settle(receipt, refusal = null) } is BadgePurchaseResult.Failed -> { - Log.e(TAG, "BadgeStore.presentPurchase: ${r.err?.string}") - val refused = badgeReceiptRefused(r.err) - if (refused) finish(receipt) - val text = chatModel.controller.redeemErrorText(r.err, purchase = true) - alertText = if (refused || retryCannotCredit(r.err)) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry) + Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}") + if (badgeReceiptRefused(r.err)) settle(receipt, refusal = r.err) } - 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 } - } - if (userWaiting && alertText != null) { - AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = alertText) + Log.e(TAG, "BadgeStore.handOver: ${e.stackTraceToString()}") } } - // at launch, on return to the foreground and on a profile switch, never on a timer + private suspend fun settle(receipt: BadgeStoreReceipt, refusal: ChatError?) { + finish(receipt) + val awaited = withContext(Dispatchers.Main) { receipt.invoiceId?.let { awaitedInvoices.remove(it) } == true } + if (awaited && refusal != null) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = chatModel.controller.redeemErrorText(refusal, purchase = true)) + } + } + + // at launch, on return to the foreground, on a profile switch and when core settles a purchase, never on a timer suspend fun presentUnfinished() { try { - if (useBadgeTestProducts || !platform.androidHasPlatformStore) return - val purchases = try { - platform.androidUnfinishedBadgePurchases() - } catch (e: Exception) { - Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") - return + if (!useBadgeTestProducts && platform.androidHasPlatformStore) { + try { + val purchases = platform.androidUnfinishedBadgePurchases() + // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here + withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } + purchases.forEach { reconcile(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") + } } - // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here - withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } - purchases.forEach { reconcile(it) } + chatModel.controller.loadBadgeState(chatModel.remoteHostId()) } finally { withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true } } @@ -337,31 +343,25 @@ object BadgeStore { when (outcome) { is BadgePurchaseOutcome.Purchased -> if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt) - else presentPurchase(outcome.receipt, interactive = false) + else handOver(outcome.receipt) is BadgePurchaseOutcome.Pending -> if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } is BadgePurchaseOutcome.Cancelled -> {} } } - // one request per purchase: the purchase itself, launch, foreground and the store can each present it - private suspend fun claim(receipt: BadgeStoreReceipt, interactive: Boolean): Boolean = withContext(Dispatchers.Main) { - val waiting = presenting[receipt.token] - if (waiting != null) { - presenting[receipt.token] = waiting || interactive - return@withContext false + private suspend fun acknowledge(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidAcknowledgeBadgePurchase(receipt) + } catch (e: Exception) { + // the next sweep hands the purchase over again, and acknowledges it then + Log.e(TAG, "BadgeStore.acknowledge: ${e.message}") } - presenting[receipt.token] = interactive - unfinished.value += receipt.token to receipt - // a slow payment that completes arrives as a purchase, which ends the wait - if (receipt.invoiceId != null) waitingForApproval.value -= receipt.invoiceId - true } private suspend fun finish(receipt: BadgeStoreReceipt) { try { if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt) - withContext(Dispatchers.Main) { unfinished.value -= receipt.token } } catch (e: Exception) { // still unfinished: the store re-delivers it, and the next answer for it finishes it Log.e(TAG, "BadgeStore.finish: ${e.message}") @@ -391,16 +391,9 @@ private fun compactPrice(product: BadgeProduct): String { } } -// the only answers after which the receipt will never be credited, so 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 private fun badgeReceiptRefused(err: ChatError?): Boolean { val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError 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/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt index bb9260f9d9..4c808d60a6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt @@ -6,17 +6,20 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource 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 @Composable -fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, onDismiss: () -> Unit) { +fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) { ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -41,6 +44,19 @@ fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, onD ) } + if (failure != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_warning), contentDescription = null, tint = Color.Red) + Text( + failure.purchaseText, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(Modifier.weight(1f)) CircularProgressIndicator( 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 e4f21df803..799bf4c673 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 @@ -28,7 +28,7 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) { BadgesYourBadgeView(badgeState, modalManager) } else if (purchaseState != null) { // holds the purchase screens' slot, so a consumable cannot be bought twice - BadgesPurchaseStateView(purchaseState.title, purchaseState.message, onDismiss = close) + BadgesPurchaseStateView(purchaseState.title, purchaseState.message, BadgeStore.creditError(chatModel.currentUser.value?.userId), onDismiss = close) } else if (checkingPurchases) { BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, null, onDismiss = close) } else { 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 65ae37cc86..deaf291138 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3181,7 +3181,6 @@ You can support SimpleX later in Settings. Support SimpleX to send larger files that stay available longer Purchase error - The purchase will be retried, and the badge will arrive. Check your order Duration Total @@ -3262,6 +3261,7 @@ Badge renewal failed Tap for details The badge service refused the renewal: %1$s + The badge service refused the purchase: %1$s The badge service did not respond. The badge service could not be reached. The badge issued by the service cannot be verified.