mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-12 15:58:03 +00:00
android: hand Play subscriptions to the core and report their renewal state
The Kotlin port of the iOS store change. A Play subscription was acknowledged uncredited; every badge product now goes through a core invoice and is handed over. The sweep queries subscriptions as well as one-time products, and the sweep and purchases listener hand over as listed, so another device on the Google account does not claim them; Restore claims them for this profile. A subscription is bought on its base plan's own offer, and the app never acknowledges one itself: the service does when it credits it, so Play still refunds one the service never credits. The app reports Play's isAutoRenewing to the core as renewing or cancelled. Run: 003-badge-subscriptions-ui, slice 11
This commit is contained in:
1 parent
69920be75e
commit
812518eeb4
10 files changed
+278
-79
No files matched your search
@@ -31,3 +31,6 @@ suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {}
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {}
|
||||
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun manageBadgeSubscription(productId: String) {}
|
||||
@@ -1,5 +1,8 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.androidPlayStoreCountry
|
||||
@@ -100,10 +103,17 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
}
|
||||
}
|
||||
|
||||
// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment
|
||||
// purchases Play still holds: one-time products bought and not consumed, subscriptions in a paid period,
|
||||
// and either awaiting a slow payment
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
|
||||
val purchases = queryBadgePurchases(client, BillingClient.ProductType.INAPP) +
|
||||
queryBadgePurchases(client, BillingClient.ProductType.SUBS)
|
||||
return purchases.mapNotNull(::badgePurchaseOutcome)
|
||||
}
|
||||
|
||||
private suspend fun queryBadgePurchases(client: BillingClient, productType: String): List<Purchase> {
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(productType).build()
|
||||
val queried = CompletableDeferred<List<Purchase>>()
|
||||
client.queryPurchasesAsync(params) { result, purchases ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
@@ -112,7 +122,7 @@ suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
|
||||
queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage))
|
||||
}
|
||||
}
|
||||
return queried.await().mapNotNull(::badgePurchaseOutcome)
|
||||
return queried.await()
|
||||
}
|
||||
|
||||
private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
|
||||
@@ -120,7 +130,7 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
|
||||
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) } }
|
||||
withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcileListed(it) } }
|
||||
}
|
||||
return@PurchasesUpdatedListener
|
||||
}
|
||||
@@ -128,7 +138,7 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
|
||||
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) } }
|
||||
withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcileListed(it) } }
|
||||
}
|
||||
result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED ->
|
||||
pending.complete(BadgePurchaseOutcome.Cancelled)
|
||||
@@ -144,7 +154,9 @@ private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = wh
|
||||
token = purchase.purchaseToken,
|
||||
productId = purchase.products.firstOrNull() ?: "",
|
||||
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId,
|
||||
acknowledged = purchase.isAcknowledged
|
||||
acknowledged = purchase.isAcknowledged,
|
||||
// Play answers false for a one-time product
|
||||
autoRenewing = if (purchase.products.firstOrNull() in badgeOneTimeProductIds) null else purchase.isAutoRenewing
|
||||
)
|
||||
)
|
||||
else -> null
|
||||
@@ -210,7 +222,8 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? {
|
||||
val product = BadgeProduct(id, purchase.formattedPrice, purchase.priceAmountMicros, purchase.priceCurrencyCode)
|
||||
return BadgeOffer(id, product, this, offerToken = null)
|
||||
}
|
||||
val offer = subscriptionOfferDetails?.firstOrNull { it.basePlanId == id.basePlanId }
|
||||
// the base plan's own offer: a promotional offer on the same base plan has an offerId and other pricing phases
|
||||
val offer = subscriptionOfferDetails?.firstOrNull { it.basePlanId == id.basePlanId && it.offerId == null }
|
||||
if (offer == null) {
|
||||
Log.w(TAG, "badgeOffer: ${id.productId} has no base plan ${id.basePlanId}, Play has ${subscriptionOfferDetails?.map { it.basePlanId }}")
|
||||
return null
|
||||
@@ -231,8 +244,8 @@ suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {
|
||||
if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token)
|
||||
}
|
||||
|
||||
// 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
|
||||
// consumed if one-time so it can be bought again; a subscription is acknowledged by the service when it credits it,
|
||||
// and Play lists it until it ends. Decided by product id, as after a restart there is no ProductDetails for the purchase
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {
|
||||
val client = connectedBadgeBillingClient()
|
||||
if (receipt.productId in badgeOneTimeProductIds) {
|
||||
@@ -240,8 +253,15 @@ suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {
|
||||
val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.consumeAsync(params) { result, _ -> done.complete(result) }
|
||||
checkBillingResult(done.await())
|
||||
} else {
|
||||
acknowledge(client, receipt.token)
|
||||
}
|
||||
}
|
||||
|
||||
fun manageBadgeSubscription(productId: String) {
|
||||
val page = Uri.parse("https://play.google.com/store/account/subscriptions?sku=$productId&package=${androidAppContext.packageName}")
|
||||
try {
|
||||
androidAppContext.startActivity(Intent(Intent.ACTION_VIEW, page).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(TAG, "manageBadgeSubscription: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = unfinishedBadgePurchases()
|
||||
|
||||
override fun androidManageBadgeSubscription(productId: String) = manageBadgeSubscription(productId)
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
override fun androidLockPortraitOrientation() {
|
||||
|
||||
Reference in new issue
Block a user