ios, android: show a pending badge purchase only on the profile that made it, and keep the buyer's alert

This commit is contained in:
spaced4ndy
2026-10-01 13:46:19 +04:00
parent 688dba47d9
commit 75e990f120
12 changed files with 100 additions and 72 deletions
+6
View File
@@ -2481,6 +2481,12 @@ func loadBadgeStateAsync(_ userId: Int64) async {
}
}
func loadCurrentBadgeState() async {
if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) {
await loadBadgeStateAsync(userId)
}
}
private func getUserChatDataAsync(keepingChatId: String?) async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
+32 -26
View File
@@ -70,13 +70,15 @@ struct BadgeStoreReceipt {
let signatureVerified: Bool
let transaction: Transaction
// core mints the id in lower case, and UUID formats it in upper case
var echoedInvoiceId: String? { invoiceId?.uuidString.lowercased() }
var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) }
}
// core mints the id in lower case, and UUID formats it in upper case
func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() }
enum BadgePurchaseOutcome {
case purchased(BadgeStoreReceipt)
case pending
case pending(invoiceId: String?)
case cancelled
}
@@ -107,10 +109,12 @@ final class BadgeStore: ObservableObject {
// invoices whose purchase this session is still waiting on the store for
@Published private var buying: Set<String> = []
// kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing
@Published private var waitingForApproval = false
// by invoice id, so a pending purchase shows only under the profile whose record it names
@Published private var waitingForApproval: Set<String> = []
// until the store has been asked once, a purchase made while the app was not running is unknown
@Published private var reconciledOnce = false
private var presenting: Set<UInt64> = []
// whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it
private var presenting: [UInt64: Bool] = [:]
private var transactionUpdates: Task<Void, Never>? = nil
private init() {}
@@ -119,7 +123,7 @@ final class BadgeStore: ObservableObject {
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 waitingForApproval { return .waitingForApproval }
if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval }
if purchases.contains(where: { $0.transactionRef == nil && $0.invoiceId.map(buying.contains) != true }) { return .checking }
return nil
}
@@ -222,18 +226,20 @@ final class BadgeStore: ObservableObject {
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
case let .success(verification): return .purchased(storeReceipt(verification))
case .pending:
await MainActor.run { waitingForApproval = true }
return .pending
let pending = coreInvoiceId(invoiceId)
await MainActor.run { _ = waitingForApproval.insert(pending) }
return .pending(invoiceId: pending)
case .userCancelled: return .cancelled
@unknown default: throw BadgeStoreError.unknownPurchaseResult
}
}
// only the purchase the user started may alert: an answer can reveal a profile other than the one on screen
func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async {
private func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async {
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }),
await claim(receipt)
await claim(receipt, interactive: interactive)
else { return }
var alertText: String? = nil
do {
switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws), retry: interactive) {
case let .redeemed(user, badgeState):
@@ -253,18 +259,14 @@ final class BadgeStore: ObservableObject {
logger.error("BadgeStore.presentPurchase: \(responseError(error))")
let refused = badgeReceiptRefused(error)
if refused { await finish(receipt) }
if interactive {
let text = redeemErrorText(error)
await MainActor.run {
showAlert(
NSLocalizedString("Purchase error", comment: "alert title"),
message: refused ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message")
)
}
}
let text = redeemErrorText(error)
alertText = refused ? 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) }
}
await loadBadgeStateAsync(userId)
await MainActor.run { _ = presenting.remove(receipt.transactionId) }
}
// at launch and on return to the foreground, never on a timer
@@ -287,7 +289,7 @@ final class BadgeStore: ObservableObject {
let abandoned = await MainActor.run {
openStorePurchases(userId)
.compactMap { $0.transactionRef == nil ? $0.invoiceId : nil }
.filter { !held.contains($0) && !buying.contains($0) }
.filter { !held.contains($0) && !buying.contains($0) && !waitingForApproval.contains($0) }
}
for invoiceId in abandoned {
await closeInvoice(userId, invoiceId)
@@ -300,7 +302,7 @@ final class BadgeStore: ObservableObject {
} catch let error {
logger.error("BadgeStore.closeInvoice: \(responseError(error))")
}
await loadBadgeStateAsync(userId)
await loadCurrentBadgeState()
}
// transactions the store settles outside a purchase call, such as an approved Ask to Buy
@@ -330,11 +332,15 @@ final class BadgeStore: ObservableObject {
// one request per transaction: the purchase itself, launch, foreground and the store can each present it
@MainActor
private func claim(_ receipt: BadgeStoreReceipt) -> Bool {
guard presenting.insert(receipt.transactionId).inserted else { return false }
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
waitingForApproval = false
if let invoiceId = receipt.echoedInvoiceId { waitingForApproval.remove(invoiceId) }
return true
}
@@ -138,7 +138,7 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
}
private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) {
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId)
Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased(
BadgeStoreReceipt(
token = purchase.purchaseToken,
@@ -113,7 +113,7 @@ private val testBadgeProducts: List<BadgeProduct> = listOf(
sealed class BadgePurchaseOutcome {
class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome()
object Pending: BadgePurchaseOutcome()
class Pending(val invoiceId: String?): BadgePurchaseOutcome()
object Cancelled: BadgePurchaseOutcome()
}
@@ -150,11 +150,13 @@ object BadgeStore {
private val storePurchases = mutableStateOf<Triple<Long?, Long, List<BadgeStorePurchase>>?>(null)
// invoices whose purchase this session is still waiting on the store for
private val buying = mutableStateOf<Set<String>>(emptySet())
private val waitingForApproval = 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())
// until the store has been asked once, 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;
// read and written on the main thread only
private val presenting = mutableSetOf<String>()
private val presenting = mutableMapOf<String, Boolean>()
fun purchaseState(userId: Long?): BadgePurchaseState? {
if (!platform.androidHasPlatformStore) return null
@@ -162,7 +164,7 @@ object BadgeStore {
val held = unfinished.value.values.mapNotNull { it.invoiceId }.toSet()
return when {
purchases.any { it.invoiceId?.let(held::contains) == true } -> BadgePurchaseState.Issuing
waitingForApproval.value -> BadgePurchaseState.WaitingForApproval
purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval
purchases.any { it.transactionRef == null && it.invoiceId?.let(buying.value::contains) != true } -> BadgePurchaseState.Checking
else -> null
}
@@ -267,15 +269,17 @@ object BadgeStore {
} else {
platform.androidPurchaseBadge(id, invoiceId)
}
if (outcome is BadgePurchaseOutcome.Pending) withContext(Dispatchers.Main) { waitingForApproval.value = true }
if (outcome is BadgePurchaseOutcome.Pending && outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId }
return outcome
}
// only the purchase the user started may alert: an answer can reveal a profile other than the one on screen
suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) {
private suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) {
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: return
if (!claim(receipt)) 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 -> {
@@ -293,19 +297,17 @@ object BadgeStore {
Log.e(TAG, "BadgeStore.presentPurchase: ${r.err?.string}")
val refused = badgeReceiptRefused(r.err)
if (refused) finish(receipt)
if (interactive) {
val text = chatModel.controller.redeemErrorText(r.err)
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_error),
text = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
)
}
val text = chatModel.controller.redeemErrorText(r.err)
alertText = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
}
null -> {}
}
} finally {
chatModel.controller.loadBadgeState(rhId)
withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) }
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)
}
}
@@ -320,7 +322,7 @@ object BadgeStore {
return
}
// Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here
withContext(Dispatchers.Main) { waitingForApproval.value = purchases.any { it is BadgePurchaseOutcome.Pending } }
withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() }
val held = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.invoiceId }.toSet()
purchases.forEach { reconcile(it) }
closeAbandoned(held)
@@ -337,7 +339,7 @@ object BadgeStore {
val abandoned = withContext(Dispatchers.Main) {
openStorePurchases(userId)
.mapNotNull { if (it.transactionRef == null) it.invoiceId else null }
.filter { it !in held && it !in buying.value }
.filter { it !in held && it !in buying.value && it !in waitingForApproval.value }
}
abandoned.forEach { closeInvoice(userId, it) }
}
@@ -357,17 +359,23 @@ object BadgeStore {
is BadgePurchaseOutcome.Purchased ->
if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt)
else presentPurchase(outcome.receipt, interactive = false)
is BadgePurchaseOutcome.Pending -> withContext(Dispatchers.Main) { waitingForApproval.value = true }
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): Boolean = withContext(Dispatchers.Main) {
if (!presenting.add(receipt.token)) return@withContext false
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
}
presenting[receipt.token] = interactive
unfinished.value += receipt.token to receipt
// a slow payment that completes arrives as a purchase, which ends the wait
waitingForApproval.value = false
if (receipt.invoiceId != null) waitingForApproval.value -= receipt.invoiceId
true
}
@@ -22,7 +22,7 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M
val purchasing = remember { mutableStateOf(false) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenPurchaseInFlight(modalManager)
CloseWhenSupportGivesWay(modalManager)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -58,7 +58,7 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenPurchaseInFlight(modalManager)
CloseWhenSupportGivesWay(modalManager)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -69,7 +69,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager) {
var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenPurchaseInFlight(modalManager)
CloseWhenSupportGivesWay(modalManager)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -99,7 +99,7 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
val code = remember { mutableStateOf(TextFieldValue("")) }
val canonicalCode = remember { mutableStateOf<String?>(null) }
val submitting = remember { mutableStateOf(false) }
CloseWhenPurchaseInFlight(modalManager)
CloseWhenSupportGivesWay(modalManager)
// when the text is unchanged, the field's own value is kept: it carries the cursor position and the
// keyboard's composition state, which BasicTextField loses unless they are passed back to it
@@ -118,7 +118,8 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
withContext(Dispatchers.Main) {
submitting.value = false
when (outcome) {
is BadgeRedeemOutcome.Redeemed -> modalManager.closeModal()
// a shown badge closes this screen itself, and a second close would take the screen beneath
is BadgeRedeemOutcome.Redeemed -> if (currentShownBadge() == null) modalManager.closeModal()
is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message)
is BadgeRedeemOutcome.Cancelled -> {}
}
@@ -12,11 +12,7 @@ import chat.simplex.common.views.helpers.ModalView
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun BadgesView(modalManager: ModalManager, close: () -> Unit) {
val shownBadge: BadgeState? = run {
if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null
val badgeState = BadgeModel.badgeState.value
if (badgeState != null && badgeState.shown) badgeState else null
}
val shownBadge = currentShownBadge()
// the card look is a modal setting, so the modal is composed here to follow the screen shown
ModalView(close, cardScreen = shownBadge != null) {
@@ -37,13 +33,19 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) {
}
}
// each purchase screen closes itself when it recomposes with a purchase in flight, as iOS pops them
// when Support SimpleX gives way to it
fun currentShownBadge(): BadgeState? {
if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return null
val badgeState = BadgeModel.badgeState.value
return if (badgeState != null && badgeState.shown) badgeState else null
}
// each purchase screen closes itself when it recomposes with a purchase in flight or a badge shown, as iOS
// pops them when Support SimpleX gives way to either
@Composable
fun CloseWhenPurchaseInFlight(modalManager: ModalManager) {
val inFlight = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null
LaunchedEffect(inFlight) {
if (inFlight) modalManager.closeModal()
fun CloseWhenSupportGivesWay(modalManager: ModalManager) {
val gaveWay = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null || currentShownBadge() != null
LaunchedEffect(gaveWay) {
if (gaveWay) modalManager.closeModal()
}
}
+4 -3
View File
@@ -237,7 +237,7 @@ data BadgeServiceErrorCode
| BSEPaymentNotEntitled
| BSEPaymentPending
| BSEProviderUnavailable
| BSEProviderNotConfigured
| BSEProviderNotConfigured -- no verifier deployed: a dev service, not the store failing to answer
| BSERateLimited
| BSECodeInvalid
| BSECodeUsed
@@ -283,8 +283,9 @@ data BadgeState = BadgeState
}
deriving (Show)
-- | A store purchase this profile started or presented, neither credited nor closed. Neither
-- reference is a secret: Apple's is the transaction id, Google's is a hash of the token.
-- | One of a profile's open store purchases, neither credited nor closed. The app matches invoiceId
-- against the transactions its store still holds; transactionRef is set once a receipt arrived.
-- Neither reference is a secret: Apple's is the transaction id, Google's is a hash of the token.
data BadgeStorePurchase = BadgeStorePurchase
{ invoiceId :: Maybe Text,
transactionRef :: Maybe Text
@@ -985,12 +985,14 @@ CREATE TABLE badge_code_redemptions(
CREATE TABLE badge_store_receipts(
badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
provider TEXT NOT NULL,
transaction_ref TEXT NOT NULL,
invoice_id TEXT UNIQUE,
provider TEXT,
transaction_ref TEXT,
purchase_key BLOB NOT NULL,
purchase_priv_key BLOB NOT NULL,
master_key BLOB NOT NULL,
created_at TEXT NOT NULL,
closed_at TEXT,
UNIQUE(provider, transaction_ref)
) STRICT;
CREATE INDEX contact_profiles_index ON contact_profiles(
+4 -2
View File
@@ -140,7 +140,7 @@ badgeServiceTests = do
it "should answer a receipt presented under a second profile as the profile that bought it" testPurchaseSameReceiptOtherProfile
it "should deliver a purchase first presented under another profile to that profile" testPurchaseStrandedUnderOtherProfile
it "should deliver a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile
it "should credit a receipt to the profile that created its invoice, and answer the presenter as delivered" testInvoiceOtherProfile
it "should credit a receipt to the profile that created its invoice, and answer as that profile" testInvoiceOtherProfile
it "should resolve the same receipt to the same record, and replay its credential" testInvoiceSameReceiptTwice
it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown
it "should reopen a closed record for a late receipt, and credit it" testInvoiceReopened
@@ -1763,7 +1763,9 @@ testInvoiceOtherProfile ps =
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> purchaseWithInvoice 2 invoiceId supporterPlay
alice <## "badge purchase delivered to another profile"
alice <## "[user: alice] badge redeemed"
alice <## "supporter badge - active"
alice <##. "expires "
(alice </)
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
alice ##> "/user alice"