From 6c7ea3400db290effa0058a8b4997f4cec1d59a2 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:22:49 +0400 Subject: [PATCH] wip --- .../foss/java/chat/simplex/app/PlayStore.kt | 3 ++- .../google/java/chat/simplex/app/PlayStore.kt | 10 ++++---- .../main/java/chat/simplex/app/SimplexApp.kt | 2 +- .../chat/simplex/common/platform/Platform.kt | 7 ++++-- .../simplex/common/views/badges/BadgeStore.kt | 23 ++++++++++++++++--- .../common/views/badges/BadgesPayView.kt | 2 ++ 6 files changed, 35 insertions(+), 12 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 2500821955..67f41b2da6 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 @@ -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): List = emptyList() +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = emptyList() @Suppress("UNUSED_PARAMETER") suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = 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 13754286aa..777993a15e 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 @@ -43,12 +43,12 @@ fun loadPlayStoreCountry() { @Volatile private var badgeProductDetails: Map = emptyMap() @Volatile private var badgePurchase: CompletableDeferred? = null -suspend fun loadBadgeProducts(productIds: List): List { +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { 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 diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 4e59babe27..30cbfa26ae 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -345,7 +345,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() - override suspend fun androidLoadBadgeProducts(productIds: List): List = loadBadgeProducts(productIds) + override suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = loadBadgeProducts(oneTimeIds, subscriptionIds) override suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(productId, invoiceId) 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 0ae9710232..1afcdda6c5 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 @@ -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): List = 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, subscriptionIds: List): List = 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 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 55d187c00d..c1ab753449 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 @@ -21,10 +21,22 @@ fun badgeProductId(level: BadgeLevel, period: BadgePeriod): String = when (level } } -val badgeProductIds: List = 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 = BadgeLevel.entries.flatMap { level -> + BadgePeriod.entries.filter { it.productType == type }.map { badgeProductId(level, it) } } +val badgeProductIds: List = 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()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt index 0fd2ac5198..b1a652be3f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -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, clipboard: ClipboardManager) { val price = BadgeStore.price(level, selectedPeriod)