From c4ec1bc2d4836ec6996eade4c43f8edcb4cb3e01 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:10:58 +0400 Subject: [PATCH] wip --- .../foss/java/chat/simplex/app/PlayStore.kt | 5 +- .../google/java/chat/simplex/app/PlayStore.kt | 54 ++++++++++++------- .../main/java/chat/simplex/app/SimplexApp.kt | 5 +- .../chat/simplex/common/platform/Platform.kt | 5 +- .../simplex/common/views/badges/BadgeStore.kt | 54 ++++++++++--------- 5 files changed, 73 insertions(+), 50 deletions(-) diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 67f41b2da6..2144c11c65 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -3,6 +3,7 @@ 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 +import chat.simplex.common.views.badges.BadgeStoreProductId // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} @@ -10,8 +11,8 @@ 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(oneTimeIds: List, subscriptionIds: List): List = emptyList() +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = emptyList() @Suppress("UNUSED_PARAMETER") -suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 329ba09413..12bbd6ca59 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -40,30 +40,40 @@ fun loadPlayStoreCountry() { // 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 = emptyMap() +@Volatile private var badgeOffers: Map = emptyMap() @Volatile private var badgePurchase: CompletableDeferred? = null -suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { +// offerToken is null for one-time products, which have no base plan to choose +private class BadgeOffer( + val id: BadgeStoreProductId, + val product: BadgeProduct, + val details: ProductDetails, + val offerToken: String? +) + +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { val client = connectedBadgeBillingClient() val details = queryBadgeProducts(client, oneTimeIds, BillingClient.ProductType.INAPP) + queryBadgeProducts(client, subscriptionIds, BillingClient.ProductType.SUBS) - val productIds = oneTimeIds + subscriptionIds - if (details.size < productIds.size) { + val detailsByProductId = details.associateBy { it.productId } + val ids = oneTimeIds + subscriptionIds + val offers = ids.mapNotNull { id -> detailsByProductId[id.productId]?.badgeOffer(id) } + if (offers.size < ids.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 - Log.w(TAG, "loadBadgeProducts: Play returned ${details.size} of ${productIds.size} - package ${androidAppContext.packageName}, country ${androidPlayStoreCountry.value ?: "none"}") + Log.w(TAG, "loadBadgeProducts: Play returned ${offers.size} of ${ids.size} - package ${androidAppContext.packageName}, country ${androidPlayStoreCountry.value ?: "none"}") } - badgeProductDetails = details.associateBy { it.productId } - return details.mapNotNull { it.badgeProduct() } + badgeOffers = offers.associateBy { it.id } + return offers.map { it.product } } -suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome { +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable val client = connectedBadgeBillingClient() - val details = badgeProductDetails[productId] ?: throw BadgeStoreError.ProductUnavailable(productId) + val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) + val details = offer.details 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) } + offer.offerToken?.let { productParams.setOfferToken(it) } val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productParams.build())) .setObfuscatedAccountId(invoiceId) @@ -136,10 +146,11 @@ private suspend fun connectedBadgeBillingClient(): BillingClient { return client } -private suspend fun queryBadgeProducts(client: BillingClient, productIds: List, productType: String): List { +private suspend fun queryBadgeProducts(client: BillingClient, ids: List, productType: String): List { val params = QueryProductDetailsParams.newBuilder() .setProductList( - productIds.map { + // durations of one subscription share a product id, so the same product is queried once + ids.map { it.productId }.distinct().map { QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build() } ) @@ -156,13 +167,18 @@ private suspend fun queryBadgeProducts(client: BillingClient, productIds: List, subscriptionIds: List): List = loadBadgeProducts(oneTimeIds, subscriptionIds) + override suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = loadBadgeProducts(oneTimeIds, subscriptionIds) - override suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(productId, invoiceId) + override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) @SuppressLint("SourceLockedOrientationActivity") @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 0be7b6aca3..066418061d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -12,6 +12,7 @@ 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 chat.simplex.common.views.badges.BadgeStoreProductId import kotlinx.coroutines.Job import java.io.Closeable @@ -37,8 +38,8 @@ interface PlatformInterface { // Play Billing, only implemented in the google flavor // 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, subscriptionIds: List): List = emptyList() - suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = emptyList() + suspend fun androidPurchaseBadge(id: BadgeStoreProductId, 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index 6c7224e6d7..75283ff5fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -6,18 +6,22 @@ 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; +// a subscription is one store product containing a base plan per duration, so a purchasable badge +// is identified by both; one-time products have no base plan +data class BadgeStoreProductId(val productId: String, val basePlanId: String? = null) + +// TODO [badges] 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) { +fun badgeStoreProductId(level: BadgeLevel, period: BadgePeriod): BadgeStoreProductId = 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" + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_supporter_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-month-02") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_supporter_01", "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" + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_legend_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-month-01") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-year-01") } } @@ -29,11 +33,11 @@ val BadgePeriod.productType: BadgeProductType BadgePeriod.Monthly, BadgePeriod.Annual -> BadgeProductType.Subscription } -fun badgeProductIds(type: BadgeProductType): List = BadgeLevel.entries.flatMap { level -> - BadgePeriod.entries.filter { it.productType == type }.map { badgeProductId(level, it) } +fun badgeStoreProductIds(type: BadgeProductType): List = BadgeLevel.entries.flatMap { level -> + BadgePeriod.entries.filter { it.productType == type }.map { badgeStoreProductId(level, it) } } -val badgeProductIds: List = BadgeProductType.entries.flatMap { badgeProductIds(it) } +val badgeStoreProductIds: List = BadgeProductType.entries.flatMap { badgeStoreProductIds(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 @@ -42,7 +46,7 @@ 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 id: BadgeStoreProductId, val displayPrice: String, val priceMicros: Long, val currencyCode: String @@ -76,7 +80,7 @@ data class BadgeStoreReceipt( const val useBadgeTestProducts = false private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) = - BadgeProduct(badgeProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") + BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") private val testBadgeProducts: List = listOf( testProduct(BadgeLevel.Supporter, BadgePeriod.OneMonth, 7_000_000), @@ -111,19 +115,19 @@ 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>(emptyMap()) + private val products = mutableStateOf>(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)] + val p = products.value[badgeStoreProductId(level, period)] if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable } } 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 monthly = products.value[badgeStoreProductId(level, BadgePeriod.Monthly)] ?: return null + val annual = products.value[badgeStoreProductId(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() @@ -136,11 +140,11 @@ object BadgeStore { // 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) + oneTimeIds = badgeStoreProductIds(BadgeProductType.OneTime), + subscriptionIds = badgeStoreProductIds(BadgeProductType.Subscription) ) - val byId = loaded.associateBy { it.productId } - val missing = badgeProductIds.filter { !byId.containsKey(it) } + val byId = loaded.associateBy { it.id } + val missing = badgeStoreProductIds.filter { !byId.containsKey(it) } if (missing.isNotEmpty()) { Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}") } @@ -153,20 +157,20 @@ object BadgeStore { } suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { - val productId = badgeProductId(level, period) - if (!products.value.containsKey(productId)) throw BadgeStoreError.ProductUnavailable(productId) + val id = badgeStoreProductId(level, period) + if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) if (useBadgeTestProducts) { return BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = "test-${UUID.randomUUID()}", - productId = productId, + productId = id.productId, orderId = null, invoiceId = invoiceId, environment = "test products" ) ) } - return platform.androidPurchaseBadge(productId, invoiceId) + return platform.androidPurchaseBadge(id, invoiceId) } private fun startLoading(): Boolean = when (state.value) {