mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-21 16:50:11 +00:00
wip
This commit is contained in:
@@ -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<String>, subscriptionIds: List<String>): List<BadgeProduct> = emptyList()
|
||||
suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> = emptyList()
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun purchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome =
|
||||
suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome =
|
||||
throw BadgeStoreError.StoreUnavailable
|
||||
|
||||
@@ -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<String, ProductDetails> = emptyMap()
|
||||
@Volatile private var badgeOffers: Map<BadgeStoreProductId, BadgeOffer> = emptyMap()
|
||||
@Volatile private var badgePurchase: CompletableDeferred<BadgePurchaseOutcome>? = null
|
||||
|
||||
suspend fun loadBadgeProducts(oneTimeIds: List<String>, subscriptionIds: List<String>): List<BadgeProduct> {
|
||||
// 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<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> {
|
||||
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<String>, productType: String): List<ProductDetails> {
|
||||
private suspend fun queryBadgeProducts(client: BillingClient, ids: List<BadgeStoreProductId>, productType: String): List<ProductDetails> {
|
||||
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<S
|
||||
return queried.await()
|
||||
}
|
||||
|
||||
private fun ProductDetails.badgeProduct(): BadgeProduct? {
|
||||
oneTimePurchaseOfferDetails?.let {
|
||||
return BadgeProduct(productId, it.formattedPrice, it.priceAmountMicros, it.priceCurrencyCode)
|
||||
private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? {
|
||||
if (id.basePlanId == null) {
|
||||
val purchase = oneTimePurchaseOfferDetails ?: return null
|
||||
val product = BadgeProduct(id, purchase.formattedPrice, purchase.priceAmountMicros, purchase.priceCurrencyCode)
|
||||
return BadgeOffer(id, product, this, offerToken = null)
|
||||
}
|
||||
// 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)
|
||||
val offer = subscriptionOfferDetails?.firstOrNull { it.basePlanId == id.basePlanId } ?: return null
|
||||
val phase = offer.pricingPhases.pricingPhaseList.firstOrNull {
|
||||
it.recurrenceMode == ProductDetails.RecurrenceMode.INFINITE_RECURRING
|
||||
} ?: return null
|
||||
val product = BadgeProduct(id, phase.formattedPrice, phase.priceAmountMicros, phase.priceCurrencyCode)
|
||||
return BadgeOffer(id, product, this, offer.offerToken)
|
||||
}
|
||||
|
||||
// nothing is delivered in this build, so the purchase is finished right away; once the service issues
|
||||
|
||||
@@ -28,6 +28,7 @@ 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.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.database.deleteOldChatArchive
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -345,9 +346,9 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry()
|
||||
|
||||
override suspend fun androidLoadBadgeProducts(oneTimeIds: List<String>, subscriptionIds: List<String>): List<BadgeProduct> = loadBadgeProducts(oneTimeIds, subscriptionIds)
|
||||
override suspend fun androidLoadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> = 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
|
||||
|
||||
+3
-2
@@ -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<String>, subscriptionIds: List<String>): List<BadgeProduct> = emptyList()
|
||||
suspend fun androidPurchaseBadge(productId: String, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable
|
||||
suspend fun androidLoadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptionIds: List<BadgeStoreProductId>): List<BadgeProduct> = 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
|
||||
|
||||
+29
-25
@@ -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<String> = BadgeLevel.entries.flatMap { level ->
|
||||
BadgePeriod.entries.filter { it.productType == type }.map { badgeProductId(level, it) }
|
||||
fun badgeStoreProductIds(type: BadgeProductType): List<BadgeStoreProductId> = BadgeLevel.entries.flatMap { level ->
|
||||
BadgePeriod.entries.filter { it.productType == type }.map { badgeStoreProductId(level, it) }
|
||||
}
|
||||
|
||||
val badgeProductIds: List<String> = BadgeProductType.entries.flatMap { badgeProductIds(it) }
|
||||
val badgeStoreProductIds: List<BadgeStoreProductId> = 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<BadgeProduct> = 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<Map<String, BadgeProduct>>(emptyMap())
|
||||
private val products = mutableStateOf<Map<BadgeStoreProductId, 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)]
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user