ui: buy a badge in the app through the App Store or Google Play (#7608)

* ui: buy a badge in the app through the App Store or Google Play

* core, ios, android: create the badge store purchase record before the store charges

* core, ios, android: fixes from the store lane review

* ios, android: show a store purchase's badge only on the profile it was credited to
This commit is contained in:
spaced4ndy
2026-10-01 08:46:35 +00:00
committed by GitHub
parent 0ee34926bc
commit e3e4709381
38 changed files with 1607 additions and 343 deletions
@@ -27,6 +27,7 @@ import chat.simplex.common.model.SMPProxyMode.Always
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.badges.BadgeStore
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.item.contentModerationPostLink
import chat.simplex.common.views.chat.item.showContentBlockedAlert
@@ -587,11 +588,40 @@ object ChatController {
}
}
// log = false because a store receipt is a bearer secret, like a badge code - it is in the command.
// null when the user cancels the retry alert, which is offered only when retry is set.
suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment, retry: Boolean): BadgePurchaseResult? {
val cmd = CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment)
val r = (if (retry) sendCmdWithRetry(rh, cmd, log = false) else sendCmd(rh, cmd, log = false)) ?: return null
return when {
r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Redeemed(r.res.user.updateRemoteHostId(rh), r.res.badgeState)
r is API.Error -> BadgePurchaseResult.Failed(r.err)
else -> {
// the response type alone - it names a case or a JSON key, never the service's message
Log.e(TAG, "apiPurchaseBadge: unexpected ${r.responseType}")
BadgePurchaseResult.Failed(null)
}
}
}
suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String {
val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId))
if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId
throw Exception("apiCreateBadgeInvoice: unexpected ${r.responseType}")
}
suspend fun apiCloseBadgeInvoice(rh: Long?, userId: Long, invoiceId: String) {
val r = sendCmd(rh, CC.ApiCloseBadgeInvoice(userId, invoiceId))
if (r is API.Result && r.res is CR.CmdOk) return
throw Exception("apiCloseBadgeInvoice: unexpected ${r.responseType}")
}
// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened
fun redeemErrorText(err: ChatError?): String {
if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) {
when (val e = err.errorType.badgeRedeemError) {
is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code)
is BadgeRedeemError.InvalidReceipt -> {}
is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured)
is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active)
is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it }
@@ -602,9 +632,9 @@ object ChatController {
return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}"
}
suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? {
suspend fun apiGetBadgeState(rh: Long?, userId: Long): Pair<BadgeState?, List<BadgeStorePurchase>> {
val r = sendCmd(rh, CC.ApiGetBadgeState(userId))
if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState
if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState to r.res.storePurchases
throw Exception("apiGetBadgeState: unexpected ${r.responseType}")
}
@@ -637,11 +667,14 @@ object ChatController {
// Not thrown: a failed badge read must not stop the app starting, and the model is left alone
// rather than set to nil, which would read as "no badge".
private suspend fun loadBadgeState(rhId: Long?) {
suspend fun loadBadgeState(rhId: Long?) {
try {
val userId = currentUserId("loadBadgeState")
val badgeState = apiGetBadgeState(rhId, userId)
withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) }
val (badgeState, storePurchases) = apiGetBadgeState(rhId, userId)
withContext(Dispatchers.Main) {
BadgeModel.set(rhId, userId, badgeState)
BadgeStore.setStorePurchases(rhId, userId, storePurchases)
}
} catch (e: Exception) {
Log.e(TAG, "loadBadgeState: ${e.message}")
}
@@ -686,6 +719,7 @@ object ChatController {
}
apiStartChat()
appPrefs.chatStopped.set(false)
withLongRunningApi { BadgeStore.presentUnfinished() }
} catch (e: Throwable) {
Log.e(TAG, "failed starting chat $e")
throw e
@@ -3622,7 +3656,7 @@ object ChatController {
getUserChatData(null)
}
private fun activeUser(rhId: Long?, user: UserLike): Boolean =
fun activeUser(rhId: Long?, user: UserLike): Boolean =
rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
@@ -3884,6 +3918,15 @@ sealed class BadgeRedeemResult {
class Failed(val err: ChatError?): BadgeRedeemResult()
}
@Serializable
data class BadgeStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null)
sealed class BadgePurchaseResult {
class Redeemed(val user: User, val badgeState: BadgeState?): BadgePurchaseResult()
// err is null for a response of an unexpected type, which is logged where it is received
class Failed(val err: ChatError?): BadgePurchaseResult()
}
// ChatCommand
// Spec: spec/api.md#CC
sealed class CC {
@@ -4064,6 +4107,9 @@ sealed class CC {
class ApiStandaloneFileInfo(val url: String): CC()
// badges
class ApiRedeemBadgeCode(val userId: Long, val code: String): CC()
class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC()
class ApiCreateBadgeInvoice(val userId: Long): CC()
class ApiCloseBadgeInvoice(val userId: Long, val invoiceId: String): CC()
class ApiGetBadgeState(val userId: Long): CC()
class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC()
class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC()
@@ -4289,6 +4335,9 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
is ApiStandaloneFileInfo -> "/_download info $url"
is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code"
is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}"
is ApiCreateBadgeInvoice -> "/_badge invoice $userId"
is ApiCloseBadgeInvoice -> "/_badge invoice close $userId $invoiceId"
is ApiGetBadgeState -> "/_badge state $userId"
is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId"
is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode"
@@ -4473,6 +4522,9 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
is ApiRedeemBadgeCode -> "apiRedeemBadgeCode"
is ApiPurchaseBadge -> "apiPurchaseBadge"
is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice"
is ApiCloseBadgeInvoice -> "apiCloseBadgeInvoice"
is ApiGetBadgeState -> "apiGetBadgeState"
is ApiGetBadgeLedger -> "apiGetBadgeLedger"
is ApiAckBadgeAlert -> "apiAckBadgeAlert"
@@ -4497,8 +4549,12 @@ sealed class CC {
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd))
is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
is TestStorageEncryption -> TestStorageEncryption(obfuscate(key))
// a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
// a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code))
is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) {
is ServicePayment.Apple -> ServicePayment.Apple(obfuscate(payment.jws))
is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token))
})
else -> this
}
@@ -4532,6 +4588,13 @@ sealed class CC {
}
}
// encoded as the base type, so the "type" tag core parses by is included
@Serializable
sealed class ServicePayment {
@Serializable @SerialName("apple") class Apple(val jws: String): ServicePayment()
@Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment()
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
// /_badge ack takes the kind in core's text encoding, not the JSON tag
@@ -6865,7 +6928,8 @@ sealed class CR {
// badges
// the full user, not UserRef: its profile carries the badge that setUserBadge just stored
@Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR()
@Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List<BadgeStorePurchase> = emptyList()): 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()
@@ -7056,6 +7120,7 @@ sealed class CR {
is ArchiveImported -> "archiveImported"
is AppSettingsR -> "appSettings"
is BadgeRedeemed -> "badgeRedeemed"
is BadgeInvoice -> "badgeInvoice"
is BadgeStateR -> "badgeState"
is BadgeLedger -> "badgeLedger"
is BadgeChanged -> "badgeChanged"
@@ -7264,7 +7329,8 @@ sealed class CR {
is ArchiveImported -> "${archiveErrors.map { it.string } }"
is AppSettingsR -> json.encodeToString(appSettings)
is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}")
is BadgeStateR -> withUser(user, json.encodeToString(badgeState))
is BadgeInvoice -> withUser(user, invoiceId)
is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}")
is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger))
is BadgeChanged -> withUser(user, json.encodeToString(badgeState))
is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert))
@@ -7323,6 +7389,7 @@ sealed class SimplexDomainError {
sealed class BadgeRedeemError {
val string: String get() = when (this) {
is InvalidCode -> "invalidCode"
is InvalidReceipt -> "invalidReceipt"
is ServiceNotConfigured -> "serviceNotConfigured"
is BadgeActive -> "badgeActive"
is ServiceError -> "serviceError ${serviceError.text}"
@@ -7331,6 +7398,7 @@ sealed class BadgeRedeemError {
is CredentialNotVerified -> "credentialNotVerified"
}
@Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError()
@Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError()
@Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError()
@Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError()
@Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError()
@@ -7352,6 +7420,7 @@ sealed class BadgeServiceErrorCode {
object PaymentNotEntitled: BadgeServiceErrorCode()
object PaymentPending: BadgeServiceErrorCode()
object ProviderUnavailable: BadgeServiceErrorCode()
object ProviderNotConfigured: BadgeServiceErrorCode()
object RateLimited: BadgeServiceErrorCode()
object CodeInvalid: BadgeServiceErrorCode()
object CodeUsed: BadgeServiceErrorCode()
@@ -7373,6 +7442,7 @@ sealed class BadgeServiceErrorCode {
is PaymentNotEntitled -> "payment_not_entitled"
is PaymentPending -> "payment_pending"
is ProviderUnavailable -> "provider_unavailable"
is ProviderNotConfigured -> "provider_not_configured"
is RateLimited -> "rate_limited"
is CodeInvalid -> "code_invalid"
is CodeUsed -> "code_used"
@@ -7392,6 +7462,8 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) {
is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version)
is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase)
is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal)
is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid)
is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used)
else -> null
}
@@ -7409,6 +7481,7 @@ object BadgeServiceErrorCodeSerializer : KSerializer<BadgeServiceErrorCode> {
"payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled
"payment_pending" -> BadgeServiceErrorCode.PaymentPending
"provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable
"provider_not_configured" -> BadgeServiceErrorCode.ProviderNotConfigured
"rate_limited" -> BadgeServiceErrorCode.RateLimited
"code_invalid" -> BadgeServiceErrorCode.CodeInvalid
"code_used" -> BadgeServiceErrorCode.CodeUsed
@@ -13,6 +13,7 @@ import chat.simplex.common.views.badges.BadgeProduct
import chat.simplex.common.views.badges.BadgePurchaseOutcome
import chat.simplex.common.views.badges.BadgeStoreError
import chat.simplex.common.views.badges.BadgeStoreProductId
import chat.simplex.common.views.badges.BadgeStoreReceipt
import kotlinx.coroutines.Job
import java.io.Closeable
@@ -44,6 +45,8 @@ interface PlatformInterface {
return emptyList()
}
suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable
suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {}
suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
val androidApiLevel: Int? get() = null
// The build distributed via Google Play, which has to follow its policies
val androidIsPlayStoreBuild: Boolean get() = false
@@ -1,7 +1,17 @@
package chat.simplex.common.views.badges
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.MR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import java.text.NumberFormat
import java.util.Currency
import java.util.UUID
@@ -29,9 +39,14 @@ val badgeStoreProductIds: List<BadgeStoreProductId> = BadgeLevel.entries.flatMap
BadgePeriod.entries.map { badgeStoreProductId(level, it) }
}
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service
// learns which invoice a store transaction settles.
// the only products sent to the badge service: nothing delivers a subscription yet
val badgeOneTimeProductIds: Set<String> = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet()
// subscriptions are for sale once renewals are delivered
val badgePeriodsForSale: List<BadgePeriod> = listOf(BadgePeriod.OneMonth)
// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core.
// Sent to Play as obfuscatedAccountId and echoed back on the purchase.
fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
// the page's app flag rides in the fragment, which never reaches the service
@@ -39,6 +54,16 @@ val badgePageUrl: String =
if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true"
else "https://badges.simplex.chat/#/tier?app=desktop"
// where the store allows a link out to the badge page: the US for now, a set expected to widen.
// An unknown country does not count, as this decides whether the store sees a link out of the app.
@Composable
fun badgeBrowserAllowed(): Boolean {
if (androidPlayStoreCountry.value == null) {
LaunchedEffect(Unit) { platform.androidLoadPlayStoreCountry() }
}
return androidPlayStoreCountry.value == "US"
}
// what the platform store knows about one product; ProductDetails cannot cross into commonMain
data class BadgeProduct(
val id: BadgeStoreProductId,
@@ -92,16 +117,24 @@ sealed class BadgePurchaseOutcome {
object Cancelled: BadgePurchaseOutcome()
}
enum class BadgePurchaseState {
Issuing,
WaitingForApproval,
Checking
}
sealed class BadgeStoreError: Exception() {
class ProductUnavailable(val productId: String): BadgeStoreError()
class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError()
object StoreUnavailable: BadgeStoreError()
object NoActiveProfile: BadgeStoreError()
override val message: String
get() = when (this) {
is ProductUnavailable -> "productUnavailable(productId: $productId)"
is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)"
is StoreUnavailable -> "storeUnavailable"
is NoActiveProfile -> "noActiveProfile"
}
}
@@ -111,6 +144,42 @@ object BadgeStore {
private val state = mutableStateOf(LoadState.NotLoaded)
// snapshot state so a composable reading only the products still recomposes when they arrive
private val products = mutableStateOf<Map<BadgeStoreProductId, BadgeProduct>>(emptyMap())
// one-time purchases the store holds unfinished: each is a payment taken and not yet credited
private val unfinished = mutableStateOf<Map<String, BadgeStoreReceipt>>(emptyMap())
// core's open store purchases for the profile they were read for: core knows whose a purchase is
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)
// until the store has been asked once, a purchase made while the app was not running is unknown
private val reconciledOnce = mutableStateOf(false)
// read and written on the main thread only
private val presenting = mutableSetOf<String>()
fun purchaseState(userId: Long?): BadgePurchaseState? {
if (!platform.androidHasPlatformStore) return null
val purchases = openStorePurchases(userId)
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.transactionRef == null && it.invoiceId?.let(buying.value::contains) != true } -> BadgePurchaseState.Checking
else -> null
}
}
fun canBuy(userId: Long?): Boolean =
reconciledOnce.value && buying.value.isEmpty() && purchaseState(userId) == null
&& openStorePurchases(userId).none { it.transactionRef == null }
fun setStorePurchases(rhId: Long?, userId: Long, purchases: List<BadgeStorePurchase>) {
storePurchases.value = Triple(rhId, userId, purchases)
}
private fun openStorePurchases(userId: Long?): List<BadgeStorePurchase> {
val (rhId, readFor, purchases) = storePurchases.value ?: return emptyList()
return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList()
}
fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) {
LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading
@@ -151,11 +220,42 @@ object BadgeStore {
}
}
suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome {
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
suspend fun purchase(level: BadgeLevel, period: BadgePeriod): Pair<BadgePurchaseOutcome, String> {
val id = badgeStoreProductId(level, period)
if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId)
if (useBadgeTestProducts) {
return BadgePurchaseOutcome.Purchased(
// a subscription is never sent to core, so nothing would ever finish it later
if (id.productId !in badgeOneTimeProductIds) {
val invoiceId = newBadgeInvoiceId()
val outcome = storePurchase(id, invoiceId)
if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt)
return outcome to invoiceId
}
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile
val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId)
withContext(Dispatchers.Main) { buying.value += invoiceId }
chatModel.controller.loadBadgeState(rhId)
try {
val outcome = storePurchase(id, invoiceId)
when (outcome) {
// finished only once the service answers for it, as an unfinished purchase is what the store re-delivers
is BadgePurchaseOutcome.Purchased -> presentPurchase(outcome.receipt, interactive = true)
is BadgePurchaseOutcome.Cancelled -> closeInvoice(userId, invoiceId)
is BadgePurchaseOutcome.Pending -> {}
}
return outcome to invoiceId
} catch (e: Exception) {
closeInvoice(userId, invoiceId)
throw e
} finally {
withContext(Dispatchers.Main + NonCancellable) { buying.value -= invoiceId }
}
}
private suspend fun storePurchase(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome {
val outcome = if (useBadgeTestProducts) {
BadgePurchaseOutcome.Purchased(
BadgeStoreReceipt(
token = "test-${UUID.randomUUID()}",
productId = id.productId,
@@ -164,8 +264,121 @@ object BadgeStore {
environment = "test products"
)
)
} else {
platform.androidPurchaseBadge(id, invoiceId)
}
if (outcome is BadgePurchaseOutcome.Pending) withContext(Dispatchers.Main) { waitingForApproval.value = true }
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) {
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: return
if (!claim(receipt)) return
try {
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token), retry = interactive)) {
is BadgePurchaseResult.Redeemed -> {
withContext(Dispatchers.Main) {
// finished below whichever profile was credited, as it is paid for; only the profile on screen shows it
if (chatModel.controller.activeUser(rhId, r.user)) {
BadgeModel.set(rhId, r.user.userId, r.badgeState)
chatModel.updateUser(r.user)
if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true)
}
}
finish(receipt)
}
is BadgePurchaseResult.Failed -> {
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)
)
}
}
null -> {}
}
} finally {
chatModel.controller.loadBadgeState(rhId)
withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) }
}
}
// at launch and on return to the foreground, never on a timer
suspend fun presentUnfinished() {
try {
if (useBadgeTestProducts || !platform.androidHasPlatformStore) return
val purchases = try {
platform.androidUnfinishedBadgePurchases()
} catch (e: Exception) {
Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}")
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 } }
val held = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.invoiceId }.toSet()
purchases.forEach { reconcile(it) }
closeAbandoned(held)
} finally {
withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true }
}
}
// A record no held purchase names and no purchase here waits on. If the store still charges for one,
// as when this runs between an invoice's creation and its purchase starting, the receipt reopens it.
private suspend fun closeAbandoned(held: Set<String>) {
val userId = chatModel.currentUser.value?.userId ?: return
chatModel.controller.loadBadgeState(chatModel.remoteHostId())
val abandoned = withContext(Dispatchers.Main) {
openStorePurchases(userId)
.mapNotNull { if (it.transactionRef == null) it.invoiceId else null }
.filter { it !in held && it !in buying.value }
}
abandoned.forEach { closeInvoice(userId, it) }
}
private suspend fun closeInvoice(userId: Long, invoiceId: String) {
val rhId = chatModel.remoteHostId()
try {
chatModel.controller.apiCloseBadgeInvoice(rhId, userId, invoiceId)
} catch (e: Exception) {
Log.e(TAG, "BadgeStore.closeInvoice: ${e.message}")
}
chatModel.controller.loadBadgeState(rhId)
}
suspend fun reconcile(outcome: BadgePurchaseOutcome) {
when (outcome) {
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.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
unfinished.value += receipt.token to receipt
// a slow payment that completes arrives as a purchase, which ends the wait
waitingForApproval.value = false
true
}
private suspend fun finish(receipt: BadgeStoreReceipt) {
try {
if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt)
withContext(Dispatchers.Main) { unfinished.value -= receipt.token }
} catch (e: Exception) {
// still unfinished: the store re-delivers it, and the next answer for it finishes it
Log.e(TAG, "BadgeStore.finish: ${e.message}")
}
return platform.androidPurchaseBadge(id, invoiceId)
}
private fun startLoading(): Boolean = when (state.value) {
@@ -190,3 +403,10 @@ private fun compactPrice(product: BadgeProduct): String {
product.displayPrice
}
}
// the only answers after which the receipt will never be credited, so the store may stop re-delivering it
private fun badgeReceiptRefused(err: ChatError?): Boolean {
val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError
val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError
return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed
}
@@ -0,0 +1,129 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.stringResource
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
@Composable
fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager) {
val purchasing = remember { mutableStateOf(false) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenPurchaseInFlight(modalManager)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
maxIntrinsicSize = true,
) {
Text(
stringResource(MR.strings.badges_check_your_order_title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Column(Modifier.fillMaxWidth().padding(top = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OrderRow(MR.strings.badges_your_badge, stringResource(level.title))
OrderRow(MR.strings.badges_order_duration, stringResource(period.label))
OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period)))
}
Text(
stringResource(MR.strings.badges_charged_by_play),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
PayButton(level, period, purchasing)
BadgeBillingFooter(period)
}
}
}
@Composable
private fun OrderRow(title: StringResource, value: String) {
Row(Modifier.fillMaxWidth()) {
Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary)
Spacer(Modifier.weight(1f))
Text(value, style = MaterialTheme.typography.body1)
}
}
@Composable
private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>) {
val price = BadgeStore.price(level, period)
val (labelId, labelArg) = period.payLabel(price)
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = labelId,
labelArg = labelArg,
onboarding = null,
enabled = price.canPurchase && !purchasing.value && BadgeStore.canBuy(chatModel.currentUser.value?.userId),
onclick = { purchase(level, period, purchasing) }
)
}
private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>) {
purchasing.value = true
// not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls
withLongRunningApi {
try {
val (outcome, invoiceId) = BadgeStore.purchase(level, period)
if (outcome is BadgePurchaseOutcome.Purchased && outcome.receipt.productId !in badgeOneTimeProductIds) {
showPurchasedAlert(outcome.receipt, invoiceId)
}
purchasing.value = false
} catch (e: Exception) {
Log.e(TAG, "BadgesCheckOrderView.purchase: ${e.stackTraceToString()}")
purchasing.value = false
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_error),
text = e.toString()
)
}
}
}
// TODO [badges] store integration diagnostics - replaced by the issued badge once subscriptions are delivered.
private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String) {
val returnedInvoice = when (receipt.invoiceId) {
null -> "none"
invoiceId -> "yes"
else -> "mismatch: ${receipt.invoiceId}"
}
val lines = mutableListOf(
"Product: ${receipt.productId}",
"Invoice: $invoiceId",
"Invoice returned by Google: $returnedInvoice",
"Order: ${receipt.orderId ?: "none"}"
)
if (receipt.environment != null) lines.add("Environment: ${receipt.environment}")
val summary = lines.joinToString("\n")
Log.d(TAG, "badge purchase succeeded\n$summary")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_successful),
text = summary
)
}
@@ -58,6 +58,7 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenPurchaseInFlight(modalManager)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -108,11 +109,12 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
// the action button (matches onboarding pattern where its own 7.5dp top padding is the gap).
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ContinueButton(selectedLevel, modalManager)
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_how_it_works_button),
icon = painterResource(MR.images.ic_info),
onClick = { modalManager.showModal { BadgesHowItWorksView() } }
)
// redeeming a code is here only when Support SimpleX offers the browser instead
if (badgeBrowserAllowed()) {
RedeemCodeButton(modalManager)
} else {
TextButtonBelowOnboardingButton("", null)
}
}
}
}
@@ -154,7 +156,7 @@ private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager
labelId = MR.strings.badges_continue,
onboarding = null,
onclick = {
modalManager.showModal { BadgesHowLongView(selectedLevel) }
modalManager.showModal { BadgesHowLongView(selectedLevel, modalManager) }
}
)
}
@@ -10,9 +10,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -68,12 +65,11 @@ enum class BadgePeriod {
}
@Composable
fun BadgesHowLongView(level: BadgeLevel) {
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) }
val purchasing = remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
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)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -102,36 +98,41 @@ fun BadgesHowLongView(level: BadgeLevel) {
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
// IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one -
// IntrinsicSize.Max + fillMaxHeight on children so the cards all match the tallest one -
// only Annual carries a savings line, and prices wrap at large fonts.
Row(
Modifier.fillMaxWidth().height(IntrinsicSize.Max),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
badgePeriodsForSale.forEach { period ->
PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
}
}
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a
// TextButton so the footer has no hover/click affordance.
Column(horizontalAlignment = Alignment.CenterHorizontally) {
PayButton(level, selectedPeriod, purchasing, clipboard)
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()),
Modifier.padding(vertical = 5.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center
)
}
ContinueButton(level, selectedPeriod, modalManager)
BadgeBillingFooter(selectedPeriod)
}
}
}
// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a
// TextButton so the footer has no hover/click affordance.
@Composable
fun BadgeBillingFooter(period: BadgePeriod) {
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
stringResource(billingFooter(period)).format(stubBillingDate()),
Modifier.padding(vertical = 5.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center
)
}
}
@Composable
private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
val isSelected = period == selectedPeriod
@@ -173,76 +174,15 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B
private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? =
if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null
// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and
// this button stays disabled - it will offer Stripe/crypto payment instead
@Composable
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
val price = BadgeStore.price(level, selectedPeriod)
val (labelId, labelArg) = selectedPeriod.payLabel(price)
private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = labelId,
labelArg = labelArg,
labelId = MR.strings.badges_continue,
onboarding = null,
enabled = price.canPurchase && !purchasing.value,
onclick = { purchase(level, selectedPeriod, purchasing, clipboard) }
)
}
private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
val invoiceId = newBadgeInvoiceId()
purchasing.value = true
// not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls
withLongRunningApi {
try {
val outcome = BadgeStore.purchase(level, period, invoiceId)
purchasing.value = false
when (outcome) {
is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard)
is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_pending),
text = generalGetString(MR.strings.badges_purchase_pending_desc)
)
is BadgePurchaseOutcome.Cancelled -> {}
}
} catch (e: Exception) {
Log.e(TAG, "BadgesHowLongView.purchase: ${e.stackTraceToString()}")
purchasing.value = false
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_error),
text = e.toString()
)
onclick = {
modalManager.showModal { BadgesCheckOrderView(level, selectedPeriod, modalManager) }
}
}
}
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) {
val returnedInvoice = when (receipt.invoiceId) {
null -> "none"
invoiceId -> "yes"
else -> "mismatch: ${receipt.invoiceId}"
}
val lines = mutableListOf(
"Product: ${receipt.productId}",
"Invoice: $invoiceId",
"Invoice returned by Google: $returnedInvoice",
"Order: ${receipt.orderId ?: "none"}"
)
if (receipt.environment != null) lines.add("Environment: ${receipt.environment}")
lines.add("Token: ${receipt.token.length} bytes")
val summary = lines.joinToString("\n")
Log.d(TAG, "badge purchase succeeded\n$summary")
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.badges_purchase_successful),
text = summary,
confirmText = "Copy token",
onConfirm = {
clipboard.setText(AnnotatedString(receipt.token))
showToast(generalGetString(MR.strings.copied))
},
dismissText = generalGetString(MR.strings.ok),
parseHtml = false
)
}
@@ -0,0 +1,65 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.res.MR
@Composable
fun BadgesPurchaseStateView(purchaseState: BadgePurchaseState) {
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
maxIntrinsicSize = true,
) {
Text(
stringResource(purchaseState.title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(purchaseState.message),
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.weight(1f))
CircularProgressIndicator(
Modifier.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
Spacer(Modifier.weight(1f))
}
}
private val BadgePurchaseState.title: StringResource
get() = when (this) {
BadgePurchaseState.Issuing -> MR.strings.badges_issuing_title
BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_title
BadgePurchaseState.Checking -> MR.strings.badges_checking_title
}
private val BadgePurchaseState.message: StringResource
get() = when (this) {
BadgePurchaseState.Issuing -> MR.strings.badges_issuing_body
BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_body
BadgePurchaseState.Checking -> MR.strings.badges_checking_body
}
@@ -99,6 +99,7 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
val code = remember { mutableStateOf(TextFieldValue("")) }
val canonicalCode = remember { mutableStateOf<String?>(null) }
val submitting = remember { mutableStateOf(false) }
CloseWhenPurchaseInFlight(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
@@ -55,8 +55,6 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) {
modifier = Modifier.fillMaxWidth()
)
// TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen
// returns to the flow and HowItWorksButton() moves there, leaving this one alone here.
HowItWorksButton(modalManager)
Spacer(Modifier.weight(1f))
@@ -66,19 +64,28 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) {
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BuyInBrowserButton(modalManager)
RedeemCodeButton(modalManager)
if (platform.androidHasPlatformStore) {
ChooseLevelButton(modalManager)
if (badgeBrowserAllowed()) {
BuyInBrowserTextButton()
} else {
RedeemCodeButton(modalManager)
}
} else {
BuyInBrowserButton(modalManager)
RedeemCodeButton(modalManager)
}
}
}
}
// the in-app purchase path, kept compiling and uncalled until payments return after the MVP
@Composable
private fun ChooseLevelButton(modalManager: ModalManager) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.badges_choose_your_level,
labelId = MR.strings.badges_choose_your_badge_title,
onboarding = null,
enabled = BadgeStore.canBuy(chatModel.currentUser.value?.userId),
onclick = {
modalManager.showModal { BadgesChooseBadgeView(modalManager) }
}
@@ -111,13 +118,22 @@ private fun HowItWorksButton(modalManager: ModalManager) {
}
@Composable
private fun RedeemCodeButton(modalManager: ModalManager) {
fun RedeemCodeButton(modalManager: ModalManager) {
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_redeem_code_button),
onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager) } }
)
}
@Composable
private fun BuyInBrowserTextButton() {
val uriHandler = LocalUriHandler.current
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_buy_in_browser),
onClick = { uriHandler.openUriCatching(badgePageUrl) }
)
}
@Composable
private fun BuyInBrowserButton(modalManager: ModalManager) {
val uriHandler = LocalUriHandler.current
@@ -2,6 +2,7 @@ package chat.simplex.common.views.badges
import androidx.compose.animation.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import chat.simplex.common.model.BadgeModel
import chat.simplex.common.model.BadgeState
import chat.simplex.common.platform.chatModel
@@ -19,9 +20,16 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) {
// the card look is a modal setting, so the modal is composed here to follow the screen shown
ModalView(close, cardScreen = shownBadge != null) {
AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState ->
AnimatedContent(
targetState = shownBadge to BadgeStore.purchaseState(chatModel.currentUser.value?.userId),
transitionSpec = { fadeIn() with fadeOut() },
contentKey = { (badgeState, purchaseState) -> (badgeState != null) to purchaseState }
) { (badgeState, purchaseState) ->
if (badgeState != null) {
BadgesYourBadgeView(badgeState, modalManager)
} else if (purchaseState != null) {
// holds the purchase screens' slot, so a consumable cannot be bought twice
BadgesPurchaseStateView(purchaseState)
} else {
BadgesSupportSimplexView(modalManager)
}
@@ -29,6 +37,16 @@ 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
@Composable
fun CloseWhenPurchaseInFlight(modalManager: ModalManager) {
val inFlight = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null
LaunchedEffect(inFlight) {
if (inFlight) modalManager.closeModal()
}
}
// ModalManager.end, not start: every caller is in the chat, which on desktop is the right pane
fun openBadgesView() {
ModalManager.end.showCustomModal { close -> BadgesView(ModalManager.end, close) }
@@ -78,6 +78,14 @@ 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)) {
@@ -3165,7 +3165,6 @@
<string name="badges_support_simplex_title">Support SimpleX</string>
<string name="badges_support_simplex_body">Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile.</string>
<string name="badges_why_simplex_is_built">Why SimpleX is built.</string>
<string name="badges_choose_your_level">Choose your level</string>
<string name="badges_redeem_code_button">Redeem badge code</string>
<string name="badges_continue">Continue</string>
<string name="badges_how_it_works_button">How badges protect your privacy</string>
@@ -3180,9 +3179,20 @@
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</string>
<string name="badges_larger_files_longer">Support SimpleX to send larger files that stay available longer</string>
<string name="badges_purchase_successful">Purchase successful</string>
<string name="badges_purchase_pending">Purchase pending</string>
<string name="badges_purchase_pending_desc">The purchase is awaiting approval. This build does not deliver purchases approved later.</string>
<string name="badges_purchase_error">Purchase error</string>
<string name="badges_purchase_will_retry">The purchase will be retried, and the badge will arrive.</string>
<string name="badges_check_your_order_title">Check your order</string>
<string name="badges_order_duration">Duration</string>
<string name="badges_order_total">Total</string>
<string name="badges_charged_by_play">Charged by Google Play to the account on this device.</string>
<string name="badges_issuing_title">Issuing your badge</string>
<string name="badges_issuing_body">The store has taken the payment. Keeps trying if you close the app.</string>
<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_title">Checking your purchase</string>
<string name="badges_checking_body">The store has not confirmed a purchase yet.</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>
@@ -3211,6 +3221,8 @@
<string name="badges_error_unsupported_version">This app version is too old for the badge service. Please update the app.</string>
<string name="badges_error_unknown_purchase">The badge service does not recognize this badge.</string>
<string name="badges_error_service_internal">The badge service reported an internal error.</string>
<string name="badges_error_receipt_invalid">The store did not confirm this purchase.</string>
<string name="badges_error_receipt_used">This purchase was already credited to a badge on another device or profile.</string>
<string name="badges_error_badge_ended">The code was accepted, but the badge it grants has already ended.</string>
<string name="badges_error_unknown">The code could not be redeemed.</string>
<string name="badges_code_you_scanned_is_not_badge_code">The code you scanned is not a badge code.</string>