mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-16 00:10:56 +00:00
core, ios, android: name the resolved store purchase so the app finishes it without a sweep (#7659)
This commit is contained in:
+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