mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-06 14:18:22 +00:00
ios, android: hand store receipts to core, finish what it settles, and show why a held purchase is not credited yet
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatResponse2>?
|
||||
if retry {
|
||||
r = await chatApiSendCmdWithRetry(cmd, log: false)
|
||||
} else {
|
||||
let res: APIResult<ChatResponse2> = 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)")
|
||||
}
|
||||
|
||||
@@ -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<String> = []
|
||||
// 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<String> = []
|
||||
private var transactionUpdates: Task<Void, Never>? = 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<Transaction>) -> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,6 +24,9 @@ suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, 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) {}
|
||||
|
||||
|
||||
@@ -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<BillingResult>()
|
||||
if (receipt.productId in badgeOneTimeProductIds) {
|
||||
val done = CompletableDeferred<BillingResult>()
|
||||
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<BillingResult>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<BadgePurchaseOutcome> = unfinishedBadgePurchases()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+14
-7
@@ -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<OpenStorePurchase>): 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<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("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
|
||||
}
|
||||
|
||||
+1
@@ -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<BadgePurchaseOutcome> = emptyList()
|
||||
val androidApiLevel: Int? get() = null
|
||||
|
||||
+62
-69
@@ -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<Map<BadgeStoreProductId, BadgeProduct>>(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<Map<String, BadgeStoreReceipt>>(emptyMap())
|
||||
// core's open store purchases for the profile they were read for: core knows whose a purchase is
|
||||
private val storePurchases = mutableStateOf<Triple<Long?, Long, List<OpenStorePurchase>>?>(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<Set<String>>(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<String, Boolean>()
|
||||
private val awaitedInvoices = mutableSetOf<String>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+17
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -3181,7 +3181,6 @@
|
||||
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</string>
|
||||
<string name="badges_larger_files_longer">Support SimpleX to send larger files that stay available longer</string>
|
||||
<string name="badges_purchase_error">Purchase error</string>
|
||||
<string name="badges_purchase_will_retry">The purchase will be retried, and the badge will arrive.</string>
|
||||
<string name="badges_check_your_order_title">Check your order</string>
|
||||
<string name="badges_order_duration">Duration</string>
|
||||
<string name="badges_order_total">Total</string>
|
||||
@@ -3262,6 +3261,7 @@
|
||||
<string name="badges_renewal_failed">Badge renewal failed</string>
|
||||
<string name="badges_tap_for_details">Tap for details</string>
|
||||
<string name="badges_error_service_refused">The badge service refused the renewal: %1$s</string>
|
||||
<string name="badges_error_purchase_refused">The badge service refused the purchase: %1$s</string>
|
||||
<string name="badges_error_no_response">The badge service did not respond.</string>
|
||||
<string name="badges_error_unreachable">The badge service could not be reached.</string>
|
||||
<string name="badges_error_credential_invalid">The badge issued by the service cannot be verified.</string>
|
||||
|
||||
Reference in New Issue
Block a user