This commit is contained in:
spaced4ndy
2026-08-17 14:22:49 +04:00
parent 54d231f838
commit 6c7ea3400d
6 changed files with 35 additions and 12 deletions
@@ -8,8 +8,9 @@ import chat.simplex.common.views.badges.BadgeStoreError
fun loadPlayStoreCountry() {}
// No store in this flavor: no product is offered, so the purchase screen shows nothing to buy
// TODO [badges] this build pays via Stripe/crypto - the badge service catalog replaces these
@Suppress("UNUSED_PARAMETER")
suspend fun loadBadgeProducts(productIds: List<String>): List<BadgeProduct> = emptyList()
suspend fun loadBadgeProducts(oneTimeIds: List<String>, subscriptionIds: List<String>): List<BadgeProduct> = emptyList()
@Suppress("UNUSED_PARAMETER")
suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome =
@@ -43,12 +43,12 @@ fun loadPlayStoreCountry() {
@Volatile private var badgeProductDetails: Map<String, ProductDetails> = emptyMap()
@Volatile private var badgePurchase: CompletableDeferred<BadgePurchaseOutcome>? = null
suspend fun loadBadgeProducts(productIds: List<String>): List<BadgeProduct> {
suspend fun loadBadgeProducts(oneTimeIds: List<String>, subscriptionIds: 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)
// two queries because Play rejects a product list mixing INAPP and SUBS
val details = queryBadgeProducts(client, oneTimeIds, BillingClient.ProductType.INAPP) +
queryBadgeProducts(client, subscriptionIds, BillingClient.ProductType.SUBS)
val productIds = oneTimeIds + subscriptionIds
if (details.size < productIds.size) {
// Play drops ids it cannot resolve without saying why, so the package it was asked for and the
// store country are logged with them - a debug build's applicationIdSuffix is a common cause
@@ -345,7 +345,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry()
override suspend fun androidLoadBadgeProducts(productIds: List<String>): List<BadgeProduct> = loadBadgeProducts(productIds)
override suspend fun androidLoadBadgeProducts(oneTimeIds: List<String>, subscriptionIds: List<String>): List<BadgeProduct> = loadBadgeProducts(oneTimeIds, subscriptionIds)
override suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(productId, invoiceId)
@@ -34,8 +34,11 @@ 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()
// Play Billing, only implemented in the google flavor. A single Play query cannot mix product
// types, so the ids are passed already grouped.
// TODO [badges] desktop and foss pay via Stripe/crypto - these defaults leave them without any
// product until that path is implemented
suspend fun androidLoadBadgeProducts(oneTimeIds: List<String>, subscriptionIds: 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
@@ -21,10 +21,22 @@ fun badgeProductId(level: BadgeLevel, period: BadgePeriod): String = when (level
}
}
val badgeProductIds: List<String> = BadgeLevel.entries.flatMap { level ->
BadgePeriod.entries.map { badgeProductId(level, it) }
// the store sells one-time products and subscriptions through separate APIs, and a single Play
// query cannot mix them
enum class BadgeProductType { OneTime, Subscription }
val BadgePeriod.productType: BadgeProductType
get() = when (this) {
BadgePeriod.OneMonth -> BadgeProductType.OneTime
BadgePeriod.Monthly, BadgePeriod.Annual -> BadgeProductType.Subscription
}
fun badgeProductIds(type: BadgeProductType): List<String> = BadgeLevel.entries.flatMap { level ->
BadgePeriod.entries.filter { it.productType == type }.map { badgeProductId(level, it) }
}
val badgeProductIds: List<String> = BadgeProductType.entries.flatMap { badgeProductIds(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.
@@ -125,7 +137,12 @@ object BadgeStore {
suspend fun load() {
if (!startLoading()) return
try {
val loaded = if (useBadgeTestProducts) testBadgeProducts else platform.androidLoadBadgeProducts(badgeProductIds)
// TODO [badges] desktop and the foss build will price from the badge service catalog and pay
// via Stripe/crypto instead of a store; only the google build reaches the platform store
val loaded = if (useBadgeTestProducts) testBadgeProducts else platform.androidLoadBadgeProducts(
oneTimeIds = badgeProductIds(BadgeProductType.OneTime),
subscriptionIds = badgeProductIds(BadgeProductType.Subscription)
)
val byId = loaded.associateBy { it.productId }
val missing = badgeProductIds.filter { !byId.containsKey(it) }
if (missing.isNotEmpty()) {
@@ -176,6 +176,8 @@ 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)