core, ui: buy a badge with an App Store or Google Play purchase (#7590)

This commit is contained in:
spaced4ndy authored and GitHub committed 2026-10-07 14:32:48 +00:00
1 parent 36da024394
commit 27d12f230d
83 files changed
+4183 -732

No files matched your search

@@ -2271,6 +2271,12 @@ sealed class BadgeIssueFailure {
is Unexpected -> String.format(generalGetString(MR.strings.badges_error_unexpected), message)
}
val purchaseText: String get() = when (this) {
is ServiceError -> badgeServiceErrorText(code) ?: String.format(generalGetString(MR.strings.badges_error_purchase_refused), code.text)
is InvalidCredential -> generalGetString(MR.strings.badges_error_credential_not_verified)
is ServiceTimeout, is Network, is Unexpected -> text
}
// the stored form, for support
val tag: String get() = when (this) {
is ServiceError -> "serviceError ${if (retryable) "retry" else "final"} ${code.text}"
@@ -27,6 +27,8 @@ 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.badges.BadgeStoreError
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.item.contentModerationPostLink
import chat.simplex.common.views.chat.item.showContentBlockedAlert
@@ -587,13 +589,38 @@ object ChatController {
}
}
// log = false because a store receipt is a bearer secret, like a badge code - it is in the command.
suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult {
val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false)
return when {
r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases)
r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(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
if (r is API.Error) throw BadgeStoreError.ApiError(r.err)
throw Exception("apiCreateBadgeInvoice: 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 {
fun redeemErrorText(err: ChatError?, purchase: Boolean): 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.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured)
is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active)
is BadgeRedeemError.InvalidReceipt -> {}
is BadgeRedeemError.ServiceNotConfigured ->
return generalGetString(if (purchase) MR.strings.badges_error_service_not_configured_purchase else MR.strings.badges_error_service_not_configured)
is BadgeRedeemError.BadgeActive ->
return generalGetString(if (purchase) MR.strings.badges_error_already_active_purchase else MR.strings.badges_error_already_active)
is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it }
is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message)
is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified)
@@ -602,9 +629,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<OpenStorePurchase>> {
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 +664,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 +716,7 @@ object ChatController {
}
apiStartChat()
appPrefs.chatStopped.set(false)
withLongRunningApi { BadgeStore.presentUnfinished() }
} catch (e: Throwable) {
Log.e(TAG, "failed starting chat $e")
throw e
@@ -751,6 +782,7 @@ object ChatController {
chatModel.users.clear()
chatModel.users.addAll(users)
getUserChatData(rhId, keepingChatId = keepingChatId)
withLongRunningApi { BadgeStore.presentUnfinished() }
val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId }
if (invitation != null && currentUser != null) {
chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser))
@@ -3581,6 +3613,9 @@ 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() }
else ->
Log.d(TAG , "unsupported event: ${msg.responseType}")
}
@@ -3622,7 +3657,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 +3919,17 @@ sealed class BadgeRedeemResult {
class Failed(val err: ChatError?): BadgeRedeemResult()
}
@Serializable
data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null, val creditError: BadgeIssueFailure? = null)
// a refusal is Failed, with the code in BREServiceError
sealed class BadgePurchaseResult {
class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List<OpenStorePurchase>): BadgePurchaseResult()
class Credited(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 +4110,8 @@ 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 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 +4337,8 @@ 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 ApiGetBadgeState -> "/_badge state $userId"
is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId"
is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode"
@@ -4473,6 +4523,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
is ApiRedeemBadgeCode -> "apiRedeemBadgeCode"
is ApiPurchaseBadge -> "apiPurchaseBadge"
is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice"
is ApiGetBadgeState -> "apiGetBadgeState"
is ApiGetBadgeLedger -> "apiGetBadgeLedger"
is ApiAckBadgeAlert -> "apiAckBadgeAlert"
@@ -4497,8 +4549,11 @@ 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.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token))
})
else -> this
}
@@ -4532,6 +4587,12 @@ sealed class CC {
}
}
// the field must stay typed as the sealed base, or kotlinx omits the "type" tag core parses by
@Serializable
sealed class 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,10 +6926,12 @@ 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<OpenStorePurchase> = 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()
@Serializable @SerialName("storePurchaseResolved") class StorePurchaseResolved(val user: UserRef): CR()
// general
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -7056,10 +7119,12 @@ sealed class CR {
is ArchiveImported -> "archiveImported"
is AppSettingsR -> "appSettings"
is BadgeRedeemed -> "badgeRedeemed"
is BadgeInvoice -> "badgeInvoice"
is BadgeStateR -> "badgeState"
is BadgeLedger -> "badgeLedger"
is BadgeChanged -> "badgeChanged"
is BadgeAlertR -> "badgeAlert"
is StorePurchaseResolved -> "storePurchaseResolved"
is Response -> "* $type"
is Invalid -> "* invalid json"
}
@@ -7264,10 +7329,12 @@ 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))
is StorePurchaseResolved -> withUser(user, noDetails())
is Response -> json
is Invalid -> str
}
@@ -7323,6 +7390,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 +7399,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 +7421,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 +7443,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 +7463,12 @@ 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)
is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending)
is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable)
is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured)
is BadgeServiceErrorCode.ProductUnavailable -> generalGetString(MR.strings.badges_error_product_unavailable)
else -> null
}
@@ -7409,6 +7486,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,9 @@ interface PlatformInterface {
return emptyList()
}
suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable
suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {}
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,16 @@
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 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
import java.util.UUID
@@ -29,15 +38,33 @@ 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
// TEST ONLY: pointed at the dev deployment; restore the two lines below before merging
val badgePageUrl: String =
if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true"
else "https://badges.simplex.chat/#/tier?app=desktop"
if (appPlatform.isAndroid) "https://smp7.simplex.im/#/tier?app=true"
else "https://smp7.simplex.im/#/tier?app=desktop"
// 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(
@@ -63,10 +90,8 @@ data class BadgeStoreReceipt(
// the token the badge service verifies with the Publisher API
val token: String,
val productId: String,
val orderId: String?,
val invoiceId: String?,
// only set for test products - a real Play purchase has no environment to report
val environment: String? = null
val acknowledged: Boolean = false
)
// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens
@@ -74,6 +99,9 @@ data class BadgeStoreReceipt(
// receipt says so.
const val useBadgeTestProducts = false
// test products stand in for a store, so the lane is offered without one
val badgeStoreAvailable: Boolean get() = useBadgeTestProducts || platform.androidHasPlatformStore
private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) =
BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD")
@@ -88,20 +116,29 @@ 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()
}
enum class BadgePurchaseState {
Issuing,
WaitingForApproval
}
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()
class ApiError(val err: ChatError): 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"
is ApiError -> "apiError(${err.string})"
}
}
@@ -111,12 +148,49 @@ 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())
// 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<OpenStorePurchase>>?>(null)
// whether a store sheet this run opened has not returned
private val buying = 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())
// 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 price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) {
fun purchaseState(userId: Long?): BadgePurchaseState? {
if (!badgeStoreAvailable) return null
val purchases = openStorePurchases(userId)
return when {
purchases.any { it.transactionRef != null } -> BadgePurchaseState.Issuing
purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval
else -> null
}
}
fun creditError(userId: Long?): BadgeIssueFailure? =
openStorePurchases(userId).firstNotNullOfOrNull { it.creditError }
val checkingPurchases: Boolean get() = badgeStoreAvailable && !reconciledOnce.value
fun canBuy(userId: Long?): Boolean =
badgeStoreAvailable && reconciledOnce.value && !buying.value && purchaseState(userId) == null
fun setStorePurchases(rhId: Long?, userId: Long, purchases: List<OpenStorePurchase>) {
storePurchases.value = Triple(rhId, userId, purchases)
}
private fun openStorePurchases(userId: Long?): List<OpenStorePurchase> {
val (rhId, readFor, purchases) = storePurchases.value ?: return emptyList()
return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList()
}
fun price(level: BadgeLevel, period: BadgePeriod, compact: Boolean = true): BadgePrice = when (state.value) {
LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading
LoadState.Loaded, LoadState.Failed -> {
val p = products.value[badgeStoreProductId(level, period)]
if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable
if (p != null) BadgePrice.Price(if (compact) compactPrice(p) else p.displayPrice) else BadgePrice.Unavailable
}
}
@@ -151,21 +225,146 @@ 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): BadgePurchaseOutcome {
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 outcome = storePurchase(id, newBadgeInvoiceId())
if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt)
return outcome
}
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 = true }
chatModel.controller.loadBadgeState(rhId)
try {
val outcome = storePurchase(id, invoiceId)
if (outcome is BadgePurchaseOutcome.Purchased) {
handOver(outcome.receipt)
}
return outcome
} finally {
withContext(Dispatchers.Main + NonCancellable) { buying.value = false }
}
}
private suspend fun storePurchase(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome {
val outcome = if (useBadgeTestProducts) {
BadgePurchaseOutcome.Purchased(
BadgeStoreReceipt(
token = "test-${UUID.randomUUID()}",
productId = id.productId,
orderId = null,
invoiceId = invoiceId,
environment = "test products"
invoiceId = invoiceId
)
)
} else {
platform.androidPurchaseBadge(id, invoiceId)
}
if (outcome is BadgePurchaseOutcome.Pending && outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId }
return outcome
}
// Core holds the receipt and credits it; the purchase stays unfinished until core answers it credited
// or refused, as an unfinished purchase is what the store re-delivers if anything is lost on the way.
private suspend fun handOver(receipt: BadgeStoreReceipt) {
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: return
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token))) {
is BadgePurchaseResult.Held -> {
// core holds it durably now, so Play must not refund it after three days unacknowledged
acknowledge(receipt)
withContext(Dispatchers.Main) {
// the answer is the owner's, which may be another profile and a hidden one
if (chatModel.controller.activeUser(rhId, r.user)) {
BadgeModel.set(rhId, r.user.userId, r.badgeState)
setStorePurchases(rhId, r.user.userId, r.storePurchases)
}
}
}
is BadgePurchaseResult.Credited -> {
withContext(Dispatchers.Main) {
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)
}
}
resolve(receipt, refusal = null)
}
// a refusal is announced where it reads as its own, 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)
} 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) }
}
}
// at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer
suspend fun presentUnfinished() {
try {
if (!useBadgeTestProducts && platform.androidHasPlatformStore) {
try {
val purchases = platform.androidUnfinishedBadgePurchases()
// Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here
withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() }
purchases.forEach { reconcile(it) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}")
}
}
chatModel.controller.loadBadgeState(chatModel.remoteHostId())
} finally {
withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true }
}
}
suspend fun reconcile(outcome: BadgePurchaseOutcome) {
when (outcome) {
is BadgePurchaseOutcome.Purchased ->
if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt)
// no buyer to tell: the purchase stays unfinished and the next sweep hands it over again
else try {
handOver(outcome.receipt)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}")
}
is BadgePurchaseOutcome.Pending ->
if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId }
is BadgePurchaseOutcome.Cancelled -> {}
}
}
private suspend fun acknowledge(receipt: BadgeStoreReceipt) {
try {
if (!useBadgeTestProducts) platform.androidAcknowledgeBadgePurchase(receipt)
} catch (e: Exception) {
// the next sweep hands the purchase over again, and acknowledges it then
Log.e(TAG, "BadgeStore.acknowledge: ${e.message}")
}
}
private suspend fun finish(receipt: BadgeStoreReceipt) {
try {
if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt)
} 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 +389,10 @@ private fun compactPrice(product: BadgeProduct): String {
product.displayPrice
}
}
// the codes core refuses a receipt with for good, after which 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
}
@@ -19,7 +19,7 @@ import chat.simplex.res.MR
import kotlinx.datetime.Instant
@Composable
fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) {
fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier) {
val user = chatModel.currentUser.value
val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname)
val previewBadge = LocalBadge(
@@ -29,15 +29,12 @@ fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing:
)
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
ProfileImage(size = 128.dp, image = user?.image)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
NameWithBadge(
name = displayName,
badge = previewBadge,
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
trailing()
}
NameWithBadge(
name = displayName,
badge = previewBadge,
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -0,0 +1,107 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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, unwindToDepth: Int) {
val purchasing = remember { mutableStateOf(false) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenSupportGivesWay(modalManager, unwindToDepth)
ColumnWithScrollBar(
Modifier.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)
.clip(RoundedCornerShape(16.dp))
.background(sectionCardColor())
.padding(horizontal = 24.dp, vertical = 12.dp)
) {
OrderRow(MR.strings.badges_your_badge, stringResource(level.title))
OrderRow(MR.strings.badges_order_duration, stringResource(period.label))
Divider(Modifier.padding(top = 8.dp, bottom = 6.dp))
OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period, compact = false)))
}
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().padding(vertical = 8.dp)) {
Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary)
Spacer(Modifier.weight(1f))
Text(value, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>) {
val price = BadgeStore.price(level, period, compact = false)
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 {
BadgeStore.purchase(level, period)
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 = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true)
else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}"
)
}
}
}
@@ -34,10 +34,16 @@ enum class BadgeLevel {
Legend -> MR.strings.badges_level_legend
}
val filesDescription: StringResource
val fileSize: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_files
Legend -> MR.strings.badges_level_legend_files
Supporter -> MR.strings.badges_level_supporter_file_size
Legend -> MR.strings.badges_level_legend_file_size
}
val fileStorage: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_file_storage
Legend -> MR.strings.badges_level_legend_file_storage
}
val summary: StringResource
@@ -54,10 +60,11 @@ enum class BadgeLevel {
}
@Composable
fun BadgesChooseBadgeView(modalManager: ModalManager) {
fun BadgesChooseBadgeView(modalManager: ModalManager, unwindToDepth: Int) {
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenSupportGivesWay(modalManager, unwindToDepth)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -82,15 +89,9 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
modifier = Modifier.fillMaxWidth()
)
BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) {
Icon(
painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp))
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
Spacer(Modifier.weight(1f).heightIn(min = 12.dp))
// IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height
// when 2-line labels at large fonts would otherwise size them differently.
@@ -102,17 +103,18 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it }
}
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
Spacer(Modifier.weight(1f).heightIn(min = 12.dp))
// Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under
// 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() } }
)
ContinueButton(selectedLevel, modalManager, unwindToDepth)
// redeeming a code is here only when Support SimpleX offers the browser instead
if (badgeBrowserAllowed()) {
RedeemCodeButton(modalManager, unwindToDepth)
} else {
TextButtonBelowOnboardingButton("", null)
}
}
}
}
@@ -131,7 +133,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo
.background(cardBackground, shape)
.border(2.dp, borderColor, shape)
.clickable { onSelect(level) }
.padding(vertical = 20.dp, horizontal = 12.dp),
.padding(vertical = 16.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
@@ -139,22 +141,25 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo
painterResource(badgeImage(level.badgeType)),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(60.dp)
modifier = Modifier.size(44.dp)
)
Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(stringResource(level.fileSize), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
Text(stringResource(level.fileStorage), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
}
}
}
@Composable
private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager) {
private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.badges_continue,
onboarding = null,
onclick = {
modalManager.showModal { BadgesHowLongView(selectedLevel) }
modalManager.showModal { BadgesHowLongView(selectedLevel, modalManager, unwindToDepth) }
}
)
}
@@ -9,10 +9,9 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -45,6 +44,13 @@ enum class BadgePeriod {
Annual -> MR.strings.badges_period_annual
}
val months: Int
get() = when (this) {
OneMonth -> 1
Monthly -> 1
Annual -> 12
}
@Composable
fun priceText(price: BadgePrice): String = when (price) {
is BadgePrice.Loading -> "…"
@@ -68,12 +74,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, unwindToDepth: Int) {
var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenSupportGivesWay(modalManager, unwindToDepth)
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -102,39 +107,46 @@ 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 }
BadgePeriod.entries.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, unwindToDepth)
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(billingDate(period)),
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
val forSale = period in badgePeriodsForSale
val textColor = if (forSale) Color.Unspecified else MaterialTheme.colors.secondary
val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f)
// Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast.
val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
@@ -145,7 +157,7 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B
.clip(shape)
.background(cardBackground, shape)
.border(2.dp, borderColor, shape)
.clickable { onSelect(period) }
.clickable(enabled = forSale) { onSelect(period) }
.padding(vertical = 20.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -154,10 +166,10 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B
painterResource(period.icon),
contentDescription = null,
tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(32.dp).alpha(if (forSale) 1f else 0.4f)
)
Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
Text(stringResource(period.label), style = MaterialTheme.typography.body2, color = textColor, textAlign = TextAlign.Center)
Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, fontWeight = FontWeight.SemiBold, color = textColor, textAlign = TextAlign.Center)
val percent = savingsPercent(level, period)
if (percent != null) {
Text(
@@ -173,76 +185,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, unwindToDepth: Int) {
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(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) }
}
}
}
// 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
)
}
@@ -251,9 +202,10 @@ private fun billingFooter(period: BadgePeriod): StringResource = when (period) {
BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month
}
// TODO [badges] source the actual date from the purchase state machine when wired.
private fun stubBillingDate(): String {
val date = java.time.LocalDate.of(2026, 7, 22)
// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld);
// a top-up must count from the end of the existing balance
private fun billingDate(period: BadgePeriod): String {
val date = java.time.LocalDate.now().plusMonths(period.months.toLong())
val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG)
return date.format(formatter)
}
@@ -0,0 +1,87 @@
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.graphics.Color
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.painterResource
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
@Composable
fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) {
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(title),
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (message != null) {
Text(
stringResource(message),
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
if (failure != null) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(painterResource(MR.images.ic_warning), contentDescription = null, tint = Color.Red)
Text(
failure.purchaseText,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.secondary,
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))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
TextButtonBelowOnboardingButton(stringResource(MR.strings.badges_dismiss), onDismiss)
TextButtonBelowOnboardingButton("", null)
}
}
}
val BadgePurchaseState.title: StringResource
get() = when (this) {
BadgePurchaseState.Issuing -> MR.strings.badges_issuing_title
BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_title
}
val BadgePurchaseState.message: StringResource
get() = when (this) {
BadgePurchaseState.Issuing -> MR.strings.badges_issuing_body
BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_body
}
@@ -85,7 +85,7 @@ suspend fun redeemBadgeCode(rhId: Long?, user: User, code: String): BadgeRedeemO
}
is BadgeRedeemResult.Failed -> {
Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}")
BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err))
BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err, purchase = false))
}
}
@@ -94,11 +94,12 @@ fun showCannotRedeemAlert(message: String) {
}
@Composable
fun BadgesRedeemCodeView(modalManager: ModalManager) {
fun BadgesRedeemCodeView(modalManager: ModalManager, unwindToDepth: Int) {
val rhId = remember { chatModel.remoteHostId() }
val code = remember { mutableStateOf(TextFieldValue("")) }
val canonicalCode = remember { mutableStateOf<String?>(null) }
val submitting = remember { mutableStateOf(false) }
CloseWhenSupportGivesWay(modalManager, unwindToDepth)
// 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
@@ -117,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 -> {}
}
@@ -382,7 +384,8 @@ private fun Confirming(onConfirm: () -> Unit, onCancel: () -> Unit) {
// leaving does not cancel: the badge is still added after the screen closes
@Composable
private fun BeingIssued(onDismiss: () -> Unit) {
LinkStep(MR.strings.badges_being_issued, textButton = MR.strings.badges_dismiss to onDismiss) {
LinkStep(MR.strings.badges_issuing_title, textButton = MR.strings.badges_dismiss to onDismiss) {
LinkText(stringResource(MR.strings.badges_link_issuing_profile))
Spacer(Modifier.weight(1f))
CircularProgressIndicator(
Modifier.size(30.dp),
@@ -32,7 +32,7 @@ import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton
import chat.simplex.res.MR
@Composable
fun BadgesSupportSimplexView(modalManager: ModalManager) {
fun BadgesSupportSimplexView(modalManager: ModalManager, unwindToDepth: Int) {
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -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,21 +64,30 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) {
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BuyInBrowserButton(modalManager)
RedeemCodeButton(modalManager)
if (badgeStoreAvailable) {
ChooseLevelButton(modalManager, unwindToDepth)
if (badgeBrowserAllowed()) {
BuyInBrowserTextButton()
} else {
RedeemCodeButton(modalManager, unwindToDepth)
}
} else {
BuyInBrowserButton(modalManager, unwindToDepth)
RedeemCodeButton(modalManager, unwindToDepth)
}
}
}
}
// the in-app purchase path, kept compiling and uncalled until payments return after the MVP
@Composable
private fun ChooseLevelButton(modalManager: ModalManager) {
private fun ChooseLevelButton(modalManager: ModalManager, unwindToDepth: Int) {
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) }
modalManager.showModal { BadgesChooseBadgeView(modalManager, unwindToDepth) }
}
)
}
@@ -111,15 +118,24 @@ private fun HowItWorksButton(modalManager: ModalManager) {
}
@Composable
private fun RedeemCodeButton(modalManager: ModalManager) {
fun RedeemCodeButton(modalManager: ModalManager, unwindToDepth: Int) {
TextButtonBelowOnboardingButton(
text = stringResource(MR.strings.badges_redeem_code_button),
onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager) } }
onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager, unwindToDepth) } }
)
}
@Composable
private fun BuyInBrowserButton(modalManager: ModalManager) {
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, unwindToDepth: Int) {
val uriHandler = LocalUriHandler.current
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
@@ -129,7 +145,7 @@ private fun BuyInBrowserButton(modalManager: ModalManager) {
uriHandler.openUriCatching(badgePageUrl)
// desktop has no scheme to bring the code back, so the code is pasted into this screen, opened beside the browser
if (appPlatform.isDesktop && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) {
modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager) }
modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) }
}
}
)
@@ -2,33 +2,70 @@ package chat.simplex.common.views.badges
import androidx.compose.animation.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@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()
val unwindToDepth = remember { modalManager.openModalCount() }
// 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 = Triple(shownBadge, BadgeStore.purchaseState(chatModel.currentUser.value?.userId), BadgeStore.checkingPurchases),
transitionSpec = { fadeIn() with fadeOut() },
contentKey = { (badgeState, purchaseState, checkingPurchases) -> Triple(badgeState != null, purchaseState, checkingPurchases) }
) { (badgeState, purchaseState, checkingPurchases) ->
if (badgeState != null) {
BadgesYourBadgeView(badgeState, modalManager)
} 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 {
BadgesSupportSimplexView(modalManager)
BadgesSupportSimplexView(modalManager, unwindToDepth)
}
}
}
}
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
}
// The badges modal shows the purchase state itself, so screens pushed over it have to go once a purchase
// appears. Only the top one is composed, so this runs there, closing them in one effect because a close per
// recomposition restarts showInView's transition, and stopping on a depth because closeModal defers removal.
// TODO [badges] ModalManager has no close-above and records no parentage; with either, the depth goes away.
@Composable
fun CloseWhenSupportGivesWay(modalManager: ModalManager, unwindToDepth: Int) {
val gaveWay = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null || currentShownBadge() != null
LaunchedEffect(gaveWay) {
if (gaveWay) while (modalManager.openModalCount() > unwindToDepth) 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)) {
@@ -3142,8 +3142,10 @@
<string name="badge_unknown_key_desc">The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.</string>
<string name="badges_level_supporter">Supporter</string>
<string name="badges_level_legend">Legend</string>
<string name="badges_level_supporter_files">Send 2GB files</string>
<string name="badges_level_legend_files">Send 5GB files</string>
<string name="badges_level_supporter_file_size">Files up to 2 GB</string>
<string name="badges_level_supporter_file_storage">Stored for 7 days</string>
<string name="badges_level_legend_file_size">Files up to 5 GB</string>
<string name="badges_level_legend_file_storage">Stored for 21 days</string>
<string name="badges_price_monthly">%1$s/month</string>
<string name="badges_price_annual">%1$s/year</string>
<string name="badges_pay_monthly">Pay %1$s/month</string>
@@ -3151,7 +3153,7 @@
<string name="badges_pay_once">Pay %1$s</string>
<string name="badges_price_loading">Loading…</string>
<string name="badges_price_unavailable">Not available</string>
<string name="badges_savings">Save %1$s</string>
<string name="badges_savings">%1$s off</string>
<string name="badges_level_supporter_summary">Supporter: 2 GB files available for 7 days.</string>
<string name="badges_level_legend_summary">Legend: 5 GB files available for 21 days.</string>
<string name="badges_period_one_month">1 month</string>
@@ -3165,7 +3167,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>
@@ -3179,10 +3180,17 @@
<string name="badges_banner_subtitle">Get badge + better files</string>
<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_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_issuing_title">Issuing your badge</string>
<string name="badges_issuing_body">Your payment is complete. The badge will be added to this profile.</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_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>
@@ -3192,7 +3200,7 @@
<string name="badges_link_held_other_profile">A badge can be added to another profile, or here once this badge ends.</string>
<string name="badges_link_held_page">Its code is on the page you bought it on, under Show code.</string>
<string name="badges_link_view_badge">View your badge</string>
<string name="badges_being_issued">Badge is being issued</string>
<string name="badges_link_issuing_profile">The badge will be added to this profile.</string>
<string name="badges_link_added_title">Badge added</string>
<string name="badges_link_added_profile">The badge was added to the profile %1$s.</string>
<string name="badges_redeem_code_body">Paste the code you received.</string>
@@ -3201,7 +3209,9 @@
<string name="badges_error_title">Cannot redeem code</string>
<string name="badges_error_invalid_code">This code is not valid.</string>
<string name="badges_error_service_not_configured">This app version cannot redeem badge codes.</string>
<string name="badges_error_service_not_configured_purchase">This app version cannot buy badges.</string>
<string name="badges_error_already_active">This profile already has a badge. Redeem the code on another profile, or once this badge ends.</string>
<string name="badges_error_already_active_purchase">This profile already has a badge.</string>
<string name="badges_error_code_invalid">This code was not recognized.</string>
<string name="badges_error_code_used">This code has already been used.</string>
<string name="badges_error_code_expired">This code has expired.</string>
@@ -3211,6 +3221,12 @@
<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_payment_pending">The payment has not been confirmed yet.</string>
<string name="badges_error_provider_unavailable">The store could not be reached.</string>
<string name="badges_error_provider_not_configured">Badges cannot be bought in the app yet.</string>
<string name="badges_error_product_unavailable">This badge is not on sale.</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>
@@ -3245,6 +3261,7 @@
<string name="badges_renewal_failed">Badge renewal failed</string>
<string name="badges_tap_for_details">Tap for details</string>
<string name="badges_error_service_refused">The badge service refused the renewal: %1$s</string>
<string name="badges_error_purchase_refused">The badge service refused the purchase: %1$s</string>
<string name="badges_error_no_response">The badge service did not respond.</string>
<string name="badges_error_unreachable">The badge service could not be reached.</string>
<string name="badges_error_credential_invalid">The badge issued by the service cannot be verified.</string>