mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-10 04:58:01 +00:00
ui: buy a badge in the app through the App Store or Google Play (#7608)
* ui: buy a badge in the app through the App Store or Google Play * core, ios, android: create the badge store purchase record before the store charges * core, ios, android: fixes from the store lane review * ios, android: show a store purchase's badge only on the profile it was credited to
This commit is contained in:
@@ -5,6 +5,7 @@ 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 chat.simplex.common.views.badges.BadgeStoreReceipt
|
||||
|
||||
// Play Billing is only in the google flavor, so the Play country stays unknown here
|
||||
fun loadPlayStoreCountry() {}
|
||||
@@ -22,3 +23,8 @@ suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptio
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome =
|
||||
throw BadgeStoreError.StoreUnavailable
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {}
|
||||
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 chat.simplex.common.views.helpers.withLongRunningApi
|
||||
import com.android.billingclient.api.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable
|
||||
val client = connectedBadgeBillingClient()
|
||||
val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId)
|
||||
val details = offer.details
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details)
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details)
|
||||
offer.offerToken?.let { productParams.setOfferToken(it) }
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(listOf(productParams.build()))
|
||||
@@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
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
|
||||
return purchase.await()
|
||||
} finally {
|
||||
badgePurchase = null
|
||||
}
|
||||
}
|
||||
|
||||
// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
|
||||
val queried = CompletableDeferred<List<Purchase>>()
|
||||
client.queryPurchasesAsync(params) { result, purchases ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
queried.complete(purchases)
|
||||
} else {
|
||||
queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage))
|
||||
}
|
||||
}
|
||||
return queried.await().mapNotNull(::badgePurchaseOutcome)
|
||||
}
|
||||
|
||||
private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
|
||||
val pending = badgePurchase ?: return@PurchasesUpdatedListener
|
||||
val pending = badgePurchase
|
||||
if (pending == null) {
|
||||
// settled outside a purchase call, such as a slow payment completing
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
|
||||
withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } }
|
||||
}
|
||||
return@PurchasesUpdatedListener
|
||||
}
|
||||
when {
|
||||
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null ->
|
||||
pending.complete(badgePurchaseOutcome(purchases))
|
||||
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
|
||||
val outcomes = purchases.mapNotNull(::badgePurchaseOutcome)
|
||||
pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled)
|
||||
withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } }
|
||||
}
|
||||
result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED ->
|
||||
pending.complete(BadgePurchaseOutcome.Cancelled)
|
||||
else ->
|
||||
@@ -114,10 +137,9 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
|
||||
}
|
||||
}
|
||||
|
||||
private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcome {
|
||||
val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled
|
||||
if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending
|
||||
return BadgePurchaseOutcome.Purchased(
|
||||
private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) {
|
||||
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending
|
||||
Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = purchase.purchaseToken,
|
||||
productId = purchase.products.firstOrNull() ?: "",
|
||||
@@ -125,6 +147,7 @@ private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcom
|
||||
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId
|
||||
)
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun connectedBadgeBillingClient(): BillingClient {
|
||||
@@ -203,21 +226,20 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? {
|
||||
return BadgeOffer(id, product, this, offer.offerToken)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase
|
||||
// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val done = CompletableDeferred<BillingResult>()
|
||||
if (details.productType == BillingClient.ProductType.SUBS) {
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.acknowledgePurchase(params) { done.complete(it) }
|
||||
} else {
|
||||
if (receipt.productId in badgeOneTimeProductIds) {
|
||||
val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.consumeAsync(params) { result, _ -> done.complete(result) }
|
||||
} else {
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.acknowledgePurchase(params) { done.complete(it) }
|
||||
}
|
||||
val result = done.await()
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}")
|
||||
throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ 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.BadgeStore
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.badges.BadgeStoreReceipt
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.database.deleteOldChatArchive
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
BadgeStore.presentUnfinished()
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
@@ -352,6 +355,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId)
|
||||
|
||||
override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt)
|
||||
|
||||
override suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = unfinishedBadgePurchases()
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
override fun androidLockPortraitOrientation() {
|
||||
|
||||
Reference in New Issue
Block a user