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