core, ios, android: name the resolved store purchase so the app finishes it without a sweep (#7659)

This commit is contained in:
spaced4ndy
2026-10-08 13:46:27 +00:00
committed by GitHub
parent c0c5ba8393
commit b4f1249436
18 changed files with 174 additions and 129 deletions
@@ -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
}
@@ -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
@@ -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) {
@@ -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) {
@@ -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 {
@@ -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>