mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 06:50:19 +00:00
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:
+82
-9
@@ -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
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+227
-7
@@ -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
|
||||
}
|
||||
|
||||
+129
@@ -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
|
||||
)
|
||||
}
|
||||
+8
-6
@@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+28
-88
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+23
-7
@@ -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
|
||||
|
||||
+19
-1
@@ -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) }
|
||||
|
||||
+8
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user