mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-06 09:58:26 +00:00
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:
+30
-22
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+3
-2
@@ -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 -> {}
|
||||
}
|
||||
|
||||
+13
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user