kotlin wip

This commit is contained in:
spaced4ndy
2026-08-14 22:10:24 +04:00
parent 255a4236ef
commit 0f5afcc119
10 changed files with 461 additions and 27 deletions
@@ -60,9 +60,6 @@
ReferencedContainer = "container:SimpleX.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../SimpleX test.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -1,4 +1,16 @@
package chat.simplex.app
import chat.simplex.common.views.badges.BadgeProduct
import chat.simplex.common.views.badges.BadgePurchaseOutcome
import chat.simplex.common.views.badges.BadgeStoreError
// Play Billing is only in the google flavor, so the Play country stays unknown here
fun loadPlayStoreCountry() {}
// No store in this flavor: no product is offered, so the purchase screen shows nothing to buy
@Suppress("UNUSED_PARAMETER")
suspend fun loadBadgeProducts(productIds: List<String>): List<BadgeProduct> = emptyList()
@Suppress("UNUSED_PARAMETER")
suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome =
throw BadgeStoreError.StoreUnavailable
@@ -1,8 +1,14 @@
package chat.simplex.app
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.androidAppContext
import chat.simplex.common.platform.androidPlayStoreCountry
import chat.simplex.common.platform.mainActivity
import chat.simplex.common.views.badges.*
import com.android.billingclient.api.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Requests the country of the Google Play account into [androidPlayStoreCountry].
// It stays null when Play is unavailable or the user is not signed in.
@@ -29,3 +35,147 @@ fun loadPlayStoreCountry() {
override fun onBillingServiceDisconnected() = client.endConnection()
})
}
// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the
// purchase is launched, and the purchase result arrives on its listener rather than as a return value.
// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher
@Volatile private var badgeBillingClient: BillingClient? = null
@Volatile private var badgeProductDetails: Map<String, ProductDetails> = emptyMap()
@Volatile private var badgePurchase: CompletableDeferred<BadgePurchaseOutcome>? = null
suspend fun loadBadgeProducts(productIds: List<String>): List<BadgeProduct> {
val client = connectedBadgeBillingClient()
// every id is queried as both types - Play returns only the ones that match, so the product type
// does not have to be inferred from the id
val details = queryBadgeProducts(client, productIds, BillingClient.ProductType.INAPP) +
queryBadgeProducts(client, productIds, BillingClient.ProductType.SUBS)
badgeProductDetails = details.associateBy { it.productId }
return details.mapNotNull { it.badgeProduct() }
}
suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome {
val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable
val client = connectedBadgeBillingClient()
val details = badgeProductDetails[productId] ?: throw BadgeStoreError.ProductUnavailable(productId)
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details)
// subscriptions must state which base plan is bought, one-time products must not
details.subscriptionOfferDetails?.firstOrNull()?.let { productParams.setOfferToken(it.offerToken) }
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams.build()))
.setObfuscatedAccountId(invoiceId)
.build()
val purchase = CompletableDeferred<BadgePurchaseOutcome>()
badgePurchase = purchase
try {
val launched = withContext(Dispatchers.Main) { client.launchBillingFlow(activity, params) }
if (launched.responseCode != BillingClient.BillingResponseCode.OK) {
throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage)
}
val outcome = purchase.await()
if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt)
return outcome
} finally {
badgePurchase = null
}
}
private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
val pending = badgePurchase ?: return@PurchasesUpdatedListener
when {
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null ->
pending.complete(badgePurchaseOutcome(purchases))
result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED ->
pending.complete(BadgePurchaseOutcome.Cancelled)
else ->
pending.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage))
}
}
private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcome {
val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled
if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending
return BadgePurchaseOutcome.Purchased(
BadgeStoreReceipt(
token = purchase.purchaseToken,
productId = purchase.products.firstOrNull() ?: "",
orderId = purchase.orderId,
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId
)
)
}
private suspend fun connectedBadgeBillingClient(): BillingClient {
badgeBillingClient?.let { if (it.isReady) return it }
val client = BillingClient.newBuilder(androidAppContext)
.setListener(badgePurchasesUpdatedListener)
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
.build()
val connected = CompletableDeferred<BillingResult>()
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
connected.complete(result)
}
override fun onBillingServiceDisconnected() {
badgeBillingClient = null
connected.complete(
BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED).build()
)
}
})
val result = connected.await()
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
client.endConnection()
throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage)
}
badgeBillingClient = client
return client
}
private suspend fun queryBadgeProducts(client: BillingClient, productIds: List<String>, productType: String): List<ProductDetails> {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
productIds.map {
QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build()
}
)
.build()
val queried = CompletableDeferred<List<ProductDetails>>()
client.queryProductDetailsAsync(params) { result, productDetailsResult ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
queried.complete(productDetailsResult.productDetailsList)
} else {
Log.w(TAG, "queryBadgeProducts: $productType query failed ${result.responseCode} ${result.debugMessage}")
queried.complete(emptyList())
}
}
return queried.await()
}
private fun ProductDetails.badgeProduct(): BadgeProduct? {
oneTimePurchaseOfferDetails?.let {
return BadgeProduct(productId, it.formattedPrice, it.priceAmountMicros, it.priceCurrencyCode)
}
// the last pricing phase is the recurring base price, earlier phases are trials and intro offers
val phase = subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.lastOrNull() ?: return null
return BadgeProduct(productId, phase.formattedPrice, phase.priceAmountMicros, phase.priceCurrencyCode)
}
// nothing is delivered in this build, so the purchase is finished right away; once the service issues
// credentials it must only be finished after the credential is stored. One-time products are consumed
// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged
// purchase after 3 days.
private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) {
val done = CompletableDeferred<BillingResult>()
if (details.productType == BillingClient.ProductType.SUBS) {
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
client.acknowledgePurchase(params) { done.complete(it) }
} else {
val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build()
client.consumeAsync(params) { result, _ -> done.complete(result) }
}
val result = done.await()
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}")
}
}
@@ -26,6 +26,8 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.badges.BadgeProduct
import chat.simplex.common.views.badges.BadgePurchaseOutcome
import chat.simplex.common.views.call.*
import chat.simplex.common.views.database.deleteOldChatArchive
import chat.simplex.common.views.helpers.*
@@ -343,6 +345,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry()
override suspend fun androidLoadBadgeProducts(productIds: List<String>): List<BadgeProduct> = loadBadgeProducts(productIds)
override suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(productId, invoiceId)
@SuppressLint("SourceLockedOrientationActivity")
@Composable
override fun androidLockPortraitOrientation() {
@@ -9,6 +9,9 @@ import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.badges.BadgeProduct
import chat.simplex.common.views.badges.BadgePurchaseOutcome
import chat.simplex.common.views.badges.BadgeStoreError
import kotlinx.coroutines.Job
import java.io.Closeable
@@ -31,6 +34,9 @@ interface PlatformInterface {
fun androidIsXiaomiDevice(): Boolean = false
// Requests the Google Play account country into [androidPlayStoreCountry]
fun androidLoadPlayStoreCountry() {}
// Play Billing, only implemented in the google flavor - elsewhere no product is offered
suspend fun androidLoadBadgeProducts(productIds: List<String>): List<BadgeProduct> = emptyList()
suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable
val androidApiLevel: Int? get() = null
// The build distributed via Google Play, which has to follow its policies
val androidIsPlayStoreBuild: Boolean get() = false
@@ -0,0 +1,150 @@
package chat.simplex.common.views.badges
import androidx.compose.runtime.mutableStateOf
import chat.simplex.common.platform.*
import java.text.NumberFormat
import java.util.Currency
import java.util.UUID
// TODO [badges] product ids will come from app config and prices from the badge service catalog;
// hardcoded here so the Play Store integration can be tested before the purchase API lands.
fun badgeProductId(level: BadgeLevel, period: BadgePeriod): String = when (level) {
BadgeLevel.Supporter -> when (period) {
BadgePeriod.OneMonth -> "BADGE_SUPPORTER_01"
BadgePeriod.Monthly -> "SUBSCR_BADGE_SUPPORTER_MONTH_01"
BadgePeriod.Annual -> "SUBSCR_BADGE_SUPPORTER_YEAR_01"
}
BadgeLevel.Legend -> when (period) {
BadgePeriod.OneMonth -> "BADGE_LEGEND_01"
BadgePeriod.Monthly -> "SUBSCR_BADGE_LEGEND_MONTH_01"
BadgePeriod.Annual -> "SUBSCR_BADGE_LEGEND_YEAR_01"
}
}
val badgeProductIds: List<String> = BadgeLevel.entries.flatMap { level ->
BadgePeriod.entries.map { badgeProductId(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.
fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
// what the platform store knows about one product; ProductDetails cannot cross into commonMain
data class BadgeProduct(
val productId: String,
val displayPrice: String,
val priceMicros: Long,
val currencyCode: String
)
sealed class BadgePrice {
object Loading: BadgePrice()
class Price(val price: String): BadgePrice()
object Unavailable: BadgePrice()
val canPurchase: Boolean
get() = when (this) {
is Price -> true
is Loading, is Unavailable -> false
}
}
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?
)
sealed class BadgePurchaseOutcome {
class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome()
object Pending: BadgePurchaseOutcome()
object Cancelled: BadgePurchaseOutcome()
}
sealed class BadgeStoreError: Exception() {
class ProductUnavailable(val productId: String): BadgeStoreError()
class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError()
object StoreUnavailable: BadgeStoreError()
override val message: String
get() = when (this) {
is ProductUnavailable -> "productUnavailable(productId: $productId)"
is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)"
is StoreUnavailable -> "storeUnavailable"
}
}
object BadgeStore {
private enum class LoadState { NotLoaded, Loading, Loaded, Failed }
private val state = mutableStateOf(LoadState.NotLoaded)
// snapshot state, unlike the plain dictionary on iOS: Compose tracks reads per value, so a
// composable that only reads the products would not recompose when they arrive
private val products = mutableStateOf<Map<String, BadgeProduct>>(emptyMap())
fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) {
LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading
LoadState.Loaded, LoadState.Failed -> {
val p = products.value[badgeProductId(level, period)]
if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable
}
}
// percentage the annual subscription saves against 12 monthly payments
fun annualSavings(level: BadgeLevel): Int? {
val monthly = products.value[badgeProductId(level, BadgePeriod.Monthly)] ?: return null
val annual = products.value[badgeProductId(level, BadgePeriod.Annual)] ?: return null
val year = monthly.priceMicros * 12
if (year <= 0 || annual.priceMicros >= year) return null
val percent = Math.round((year - annual.priceMicros).toDouble() / year * 100).toInt()
return if (percent > 0) percent else null
}
suspend fun load() {
if (!startLoading()) return
try {
val loaded = platform.androidLoadBadgeProducts(badgeProductIds)
val byId = loaded.associateBy { it.productId }
val missing = badgeProductIds.filter { !byId.containsKey(it) }
if (missing.isNotEmpty()) {
Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}")
}
products.value = byId
state.value = LoadState.Loaded
} catch (e: Exception) {
Log.e(TAG, "BadgeStore.load: ${e.stackTraceToString()}")
state.value = LoadState.Failed
}
}
suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome {
val productId = badgeProductId(level, period)
if (!products.value.containsKey(productId)) throw BadgeStoreError.ProductUnavailable(productId)
return platform.androidPurchaseBadge(productId, invoiceId)
}
private fun startLoading(): Boolean = when (state.value) {
LoadState.NotLoaded, LoadState.Failed -> {
state.value = LoadState.Loading
true
}
LoadState.Loading, LoadState.Loaded -> false
}
}
// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency;
// BadgeProduct.displayPrice remains the exact form for views that need the cents
private fun compactPrice(product: BadgeProduct): String {
if (product.priceMicros % 1_000_000L != 0L) return product.displayPrice
return try {
val format = NumberFormat.getCurrencyInstance()
format.currency = Currency.getInstance(product.currencyCode)
format.maximumFractionDigits = 0
format.format(product.priceMicros / 1_000_000L)
} catch (e: Exception) {
product.displayPrice
}
}
@@ -10,6 +10,9 @@ 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
@@ -25,24 +28,54 @@ import chat.simplex.res.MR
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum class BadgePeriod {
OneMonth,
Subscribe;
Monthly,
Annual;
val icon: dev.icerock.moko.resources.ImageResource
get() = when (this) {
OneMonth -> MR.images.ic_calendar
Subscribe -> MR.images.ic_refresh
Monthly -> MR.images.ic_refresh
Annual -> MR.images.ic_refresh
}
val label: StringResource
get() = when (this) {
OneMonth -> MR.strings.badges_period_one_month
Subscribe -> MR.strings.badges_period_subscribe
Monthly -> MR.strings.badges_period_monthly
Annual -> MR.strings.badges_period_annual
}
@Composable
fun priceText(price: BadgePrice): String = when (price) {
is BadgePrice.Loading -> ""
is BadgePrice.Unavailable -> ""
is BadgePrice.Price -> when (this) {
OneMonth -> price.price
Monthly -> stringResource(MR.strings.badges_price_monthly).format(price.price)
Annual -> stringResource(MR.strings.badges_price_annual).format(price.price)
}
}
// OnboardingActionButton takes a resource id, so the price states map to (id, arg) here rather
// than to a formatted string as on iOS
fun payLabel(price: BadgePrice): Pair<StringResource, String?> = when (price) {
is BadgePrice.Loading -> MR.strings.badges_price_loading to null
is BadgePrice.Unavailable -> MR.strings.badges_price_unavailable to null
is BadgePrice.Price -> when (this) {
OneMonth -> MR.strings.badges_pay_once to price.price
Monthly -> MR.strings.badges_pay_monthly to price.price
Annual -> MR.strings.badges_pay_annual to price.price
}
}
}
@Composable
fun BadgesPayView(level: BadgeLevel) {
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) }
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) }
val purchasing = remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
LaunchedEffect(Unit) { BadgeStore.load() }
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
@@ -71,14 +104,15 @@ fun BadgesPayView(level: BadgeLevel) {
Spacer(Modifier.weight(1f).heightIn(min = 20.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.
// IntrinsicSize.Max + fillMaxHeight on children so all three cards 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(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
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 }
}
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
@@ -86,7 +120,7 @@ fun BadgesPayView(level: BadgeLevel) {
// 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)
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()),
@@ -101,7 +135,7 @@ fun BadgesPayView(level: BadgeLevel) {
}
@Composable
private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
val isSelected = period == selectedPeriod
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.
@@ -125,24 +159,94 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie
modifier = Modifier.size(32.dp)
)
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)
val percent = savingsPercent(level, period)
if (percent != null) {
Text(
stringResource(MR.strings.badges_savings).format(percent),
style = MaterialTheme.typography.caption,
color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
textAlign = TextAlign.Center
)
}
}
}
private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? =
if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null
@Composable
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) {
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
val price = BadgeStore.price(level, selectedPeriod)
val (labelId, labelArg) = selectedPeriod.payLabel(price)
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once,
labelArg = level.priceAmount,
labelId = labelId,
labelArg = labelArg,
onboarding = null,
onclick = {
// TODO [badges] wire to purchase API when it lands.
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, "BadgesPayView.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 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 summary = listOf(
"Product: ${receipt.productId}",
"Invoice: $invoiceId",
"Invoice returned by Google: $returnedInvoice",
"Order: ${receipt.orderId ?: "none"}",
"Token: ${receipt.token.length} bytes"
).joinToString("\n")
// logged as well as shown: the log always lands even when the alert is missed
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
)
}
private fun billingFooter(period: BadgePeriod): StringResource = when (period) {
BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe
BadgePeriod.Monthly, BadgePeriod.Annual -> MR.strings.badges_billing_footer_subscribe
BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month
}
@@ -33,6 +33,9 @@ import chat.simplex.res.MR
@Composable
fun BadgesSupportSimplexView() {
// TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge")
// preloaded here so the level screen shows store prices without a placeholder pass
LaunchedEffect(Unit) { BadgeStore.load() }
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -40,12 +40,6 @@ enum class BadgeLevel {
Legend -> MR.strings.badges_level_legend_files
}
val priceAmount: String
get() = when (this) {
Supporter -> "$7"
Legend -> "$70"
}
val tagline: StringResource
get() = when (this) {
Supporter -> MR.strings.badges_level_supporter_tagline
@@ -63,6 +57,8 @@ enum class BadgeLevel {
fun BadgesYourLevelView() {
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
LaunchedEffect(Unit) { BadgeStore.load() }
ColumnWithScrollBar(
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -139,7 +135,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo
)
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(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center)
}
}
@@ -3206,12 +3206,18 @@
<string name="badges_level_supporter_files">Send 2GB files</string>
<string name="badges_level_legend_files">Send 5GB files</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>
<string name="badges_pay_annual">Pay %1$s/year</string>
<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$d%%</string>
<string name="badges_level_supporter_tagline">Optional profile badge\nand 2GB files</string>
<string name="badges_level_legend_tagline">Optional profile badge\nand 5GB files</string>
<string name="badges_period_one_month">1 month</string>
<string name="badges_period_subscribe">Subscribe</string>
<string name="badges_period_monthly">Monthly</string>
<string name="badges_period_annual">Annual</string>
<string name="badges_billing_footer_subscribe">Renews on %1$s. Cancel anytime.</string>
<string name="badges_billing_footer_one_month">Ends on %1$s.</string>
<string name="badges_your_level_title">Your level</string>
@@ -3230,6 +3236,10 @@
<string name="badges_banner_title">Support SimpleX</string>
<string name="badges_banner_subtitle">Get badge + files up to 5GB</string>
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</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="supporter_perks">Supporter perks</string>
<string name="v7_1_supporter_badge_title">Supporter badge ❤️</string>
<string name="v7_1_supporter_badge_body">Help keep the network running — send files up to 2 GB.</string>