From 0f5afcc1191b7859c5cbf40630f5ee8c901a0d45 Mon Sep 17 00:00:00 2001
From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Date: Fri, 14 Aug 2026 22:10:24 +0400
Subject: [PATCH] kotlin wip
---
.../xcschemes/SimpleX (iOS).xcscheme | 3 -
.../foss/java/chat/simplex/app/PlayStore.kt | 12 ++
.../google/java/chat/simplex/app/PlayStore.kt | 150 ++++++++++++++++++
.../main/java/chat/simplex/app/SimplexApp.kt | 6 +
.../chat/simplex/common/platform/Platform.kt | 6 +
.../simplex/common/views/badges/BadgeStore.kt | 150 ++++++++++++++++++
.../common/views/badges/BadgesPayView.kt | 136 ++++++++++++++--
.../views/badges/BadgesSupportSimplexView.kt | 3 +
.../views/badges/BadgesYourLevelView.kt | 10 +-
.../commonMain/resources/MR/base/strings.xml | 12 +-
10 files changed, 461 insertions(+), 27 deletions(-)
create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt
diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme
index 7394bc272a..6a1d4192e6 100644
--- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme
+++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme
@@ -60,9 +60,6 @@
ReferencedContainer = "container:SimpleX.xcodeproj">
-
-
): List = emptyList()
+
+@Suppress("UNUSED_PARAMETER")
+suspend fun purchaseBadge(productId: String, 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 a0e7734ff0..125f8351f0 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
@@ -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 = emptyMap()
+@Volatile private var badgePurchase: CompletableDeferred? = null
+
+suspend fun loadBadgeProducts(productIds: 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)
+ 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()
+ 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): 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()
+ 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, productType: String): List {
+ val params = QueryProductDetailsParams.newBuilder()
+ .setProductList(
+ productIds.map {
+ QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build()
+ }
+ )
+ .build()
+ val queried = CompletableDeferred>()
+ 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()
+ 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}")
+ }
+}
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 ce47d2c5de..4e59babe27 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
@@ -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): List = loadBadgeProducts(productIds)
+
+ override suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(productId, invoiceId)
+
@SuppressLint("SourceLockedOrientationActivity")
@Composable
override fun androidLockPortraitOrientation() {
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 b46123c9cf..0ae9710232 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
@@ -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): 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
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
new file mode 100644
index 0000000000..f2d9018991
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt
@@ -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 = 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