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:
spaced4ndy
2026-10-06 13:34:17 +04:00
parent ba1731c551
commit 110aa53c35
16 changed files with 208 additions and 167 deletions
+4
View File
@@ -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
}
}
}
+10 -13
View File
@@ -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)")
}
+42 -69
View File
@@ -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)
+10
View File
@@ -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}"
@@ -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
}
@@ -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
@@ -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
}
@@ -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(
@@ -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>