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:
spacedandy-agents-simplex committed 2026-10-15 07:16:25 +00:00
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() {
@@ -130,7 +130,7 @@ object BadgeModel {
this.rhId.value == rhId && this.userId.value == userId
}
enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake }
enum class ChatListBanner { BadgeAlert, BadgePitch, GetStake }
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
@@ -2242,11 +2242,52 @@ data class BadgeState(
val willRenew: Boolean,
val alert: BadgeAlert? = null,
val issueError: BadgeIssueError? = null,
val nextWakeAt: Instant? = null
val nextWakeAt: Instant? = null,
val subscription: BadgeSubscription? = null
) {
val paidThroughText: String get() = badgeDateText(paidThrough)
}
@Serializable
data class BadgeSubscription(
val planMonths: Int,
// null until the service or the device's store has been asked
val status: BadgeSubscriptionState? = null
)
// the store's state as last observed; a newer core may know a state this version does not
@Serializable(with = BadgeSubscriptionStateSerializer::class)
enum class BadgeSubscriptionState {
Renewing,
Cancelled,
BillingRetry,
Ended,
Unknown;
// as /_badge subscription takes it, which accepts only the four known states
val text: String
get() = when (this) {
Renewing -> "renewing"
Cancelled -> "cancelled"
BillingRetry -> "billingRetry"
Ended -> "ended"
Unknown -> "unknown"
}
}
object BadgeSubscriptionStateSerializer : KSerializer<BadgeSubscriptionState> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeSubscriptionState", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): BadgeSubscriptionState =
when (decoder.decodeString()) {
"renewing" -> BadgeSubscriptionState.Renewing
"cancelled" -> BadgeSubscriptionState.Cancelled
"billingRetry" -> BadgeSubscriptionState.BillingRetry
"ended" -> BadgeSubscriptionState.Ended
else -> BadgeSubscriptionState.Unknown
}
override fun serialize(encoder: Encoder, value: BadgeSubscriptionState) = encoder.encodeString(value.text)
}
@Serializable
data class BadgeIssueError(
val failedSince: Instant,
@@ -2431,14 +2472,45 @@ data class BadgeAlert(
@Serializable
data class BadgeAlertPrice(val amount: Long, val currency: String)
@Serializable
enum class BadgeAlertKind {
@SerialName("renewalApproaching") RenewalApproaching,
@SerialName("paymentIssue") PaymentIssue,
@SerialName("subscriptionEnded") SubscriptionEnded,
@SerialName("prepaidEnding") PrepaidEnding,
@SerialName("supportEnded") SupportEnded,
@SerialName("issueFailed") IssueFailed
// a kind added by a newer core decodes, so the badge state still does, and shows no banner
@Serializable(with = BadgeAlertKindSerializer::class)
sealed class BadgeAlertKind {
object RenewalApproaching: BadgeAlertKind()
object PaymentIssue: BadgeAlertKind()
object SubscriptionCancelled: BadgeAlertKind()
object SubscriptionEnded: BadgeAlertKind()
object PrepaidEnding: BadgeAlertKind()
object SupportEnded: BadgeAlertKind()
object IssueFailed: BadgeAlertKind()
data class Unknown(val kind: String): BadgeAlertKind()
val text: String
get() = when (this) {
is RenewalApproaching -> "renewalApproaching"
is PaymentIssue -> "paymentIssue"
is SubscriptionCancelled -> "subscriptionCancelled"
is SubscriptionEnded -> "subscriptionEnded"
is PrepaidEnding -> "prepaidEnding"
is SupportEnded -> "supportEnded"
is IssueFailed -> "issueFailed"
is Unknown -> kind
}
}
object BadgeAlertKindSerializer : KSerializer<BadgeAlertKind> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeAlertKind", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): BadgeAlertKind =
when (val v = decoder.decodeString()) {
"renewalApproaching" -> BadgeAlertKind.RenewalApproaching
"paymentIssue" -> BadgeAlertKind.PaymentIssue
"subscriptionCancelled" -> BadgeAlertKind.SubscriptionCancelled
"subscriptionEnded" -> BadgeAlertKind.SubscriptionEnded
"prepaidEnding" -> BadgeAlertKind.PrepaidEnding
"supportEnded" -> BadgeAlertKind.SupportEnded
"issueFailed" -> BadgeAlertKind.IssueFailed
else -> BadgeAlertKind.Unknown(v)
}
override fun serialize(encoder: Encoder, value: BadgeAlertKind) = encoder.encodeString(value.text)
}
private fun badgeDateText(date: Instant): String {
@@ -593,11 +593,13 @@ object ChatController {
}
// log = false because a store receipt is a bearer secret, like a badge code - it is in the command.
suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult {
val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false)
// listed: found in the store's list rather than bought or restored here, so core records it only into a record it has.
suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, listed: Boolean, payment: ServicePayment): BadgePurchaseResult {
val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, listed, payment), log = false)
return when {
r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases)
r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(r.res.user.updateRemoteHostId(rh), r.res.badgeState)
r is API.Result && r.res is CR.StoreReceiptNotRecorded -> BadgePurchaseResult.NotRecorded
r is API.Error -> BadgePurchaseResult.Failed(r.err)
else -> {
// the response type alone - it names a case or a JSON key, never the service's message
@@ -607,6 +609,14 @@ object ChatController {
}
}
// the device store's view of a subscription it charges, which changes only what is shown and alerted
suspend fun apiSetBadgeSubscriptionStatus(rh: Long?, status: BadgeSubscriptionState, payment: ServicePayment) {
val r = sendCmd(rh, CC.ApiSetBadgeSubscriptionStatus(status, payment), log = false)
if (r is API.Result && r.res is CR.CmdOk) return
if (r is API.Error) throw BadgeStoreError.ApiError(r.err)
throw Exception("apiSetBadgeSubscriptionStatus: unexpected ${r.responseType}")
}
suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String {
val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId))
if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId
@@ -624,6 +634,8 @@ object ChatController {
return generalGetString(if (purchase) MR.strings.badges_error_service_not_configured_purchase else MR.strings.badges_error_service_not_configured)
is BadgeRedeemError.BadgeActive ->
return generalGetString(if (purchase) MR.strings.badges_error_already_active_purchase else MR.strings.badges_error_already_active)
is BadgeRedeemError.StorePurchaseOpen ->
return generalGetString(if (purchase) MR.strings.badges_error_store_purchase_open_purchase else MR.strings.badges_error_store_purchase_open)
is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it }
is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message)
is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified)
@@ -3966,6 +3978,8 @@ data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef:
sealed class BadgePurchaseResult {
class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List<OpenStorePurchase>): BadgePurchaseResult()
class Credited(val user: User, val badgeState: BadgeState?): BadgePurchaseResult()
// listed, and no record here has it: another device's purchase on the same Google account
object NotRecorded: BadgePurchaseResult()
// err is null for a response of an unexpected type, which is logged where it is received
class Failed(val err: ChatError?): BadgePurchaseResult()
}
@@ -4150,7 +4164,8 @@ sealed class CC {
class ApiStandaloneFileInfo(val url: String): CC()
// badges
class ApiRedeemBadgeCode(val userId: Long, val code: String): CC()
class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC()
class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val listed: Boolean, val payment: ServicePayment): CC()
class ApiSetBadgeSubscriptionStatus(val status: BadgeSubscriptionState, val payment: ServicePayment): CC()
class ApiCreateBadgeInvoice(val userId: Long): CC()
class ApiGetBadgeState(val userId: Long): CC()
class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC()
@@ -4377,7 +4392,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
is ApiStandaloneFileInfo -> "/_download info $url"
is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code"
is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}"
is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""}${if (listed) " listed" else ""} ${json.encodeToString(payment)}"
is ApiSetBadgeSubscriptionStatus -> "/_badge subscription ${status.text} ${json.encodeToString(payment)}"
is ApiCreateBadgeInvoice -> "/_badge invoice $userId"
is ApiGetBadgeState -> "/_badge state $userId"
is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId"
@@ -4564,6 +4580,7 @@ sealed class CC {
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
is ApiRedeemBadgeCode -> "apiRedeemBadgeCode"
is ApiPurchaseBadge -> "apiPurchaseBadge"
is ApiSetBadgeSubscriptionStatus -> "apiSetBadgeSubscriptionStatus"
is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice"
is ApiGetBadgeState -> "apiGetBadgeState"
is ApiGetBadgeLedger -> "apiGetBadgeLedger"
@@ -4591,7 +4608,10 @@ sealed class CC {
is TestStorageEncryption -> TestStorageEncryption(obfuscate(key))
// a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code))
is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) {
is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, listed, when (payment) {
is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token))
})
is ApiSetBadgeSubscriptionStatus -> ApiSetBadgeSubscriptionStatus(status, when (payment) {
is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token))
})
else -> this
@@ -4635,14 +4655,16 @@ sealed class ServicePayment {
fun onOff(b: Boolean): String = if (b) "on" else "off"
// /_badge ack takes the kind in core's text encoding, not the JSON tag
// /_badge ack takes the kind in core's text encoding, not the JSON tag; an unknown kind has no banner to dismiss, so it is never acked
private fun badgeAlertKindParam(kind: BadgeAlertKind): String = when (kind) {
BadgeAlertKind.RenewalApproaching -> "renewal_approaching"
BadgeAlertKind.PaymentIssue -> "payment_issue"
BadgeAlertKind.SubscriptionEnded -> "subscription_ended"
BadgeAlertKind.PrepaidEnding -> "prepaid_ending"
BadgeAlertKind.SupportEnded -> "support_ended"
BadgeAlertKind.IssueFailed -> "issue_failed"
is BadgeAlertKind.RenewalApproaching -> "renewal_approaching"
is BadgeAlertKind.PaymentIssue -> "payment_issue"
is BadgeAlertKind.SubscriptionCancelled -> "subscription_cancelled"
is BadgeAlertKind.SubscriptionEnded -> "subscription_ended"
is BadgeAlertKind.PrepaidEnding -> "prepaid_ending"
is BadgeAlertKind.SupportEnded -> "support_ended"
is BadgeAlertKind.IssueFailed -> "issue_failed"
is BadgeAlertKind.Unknown -> kind.kind
}
@Serializable
@@ -6971,6 +6993,7 @@ sealed class CR {
// the full user, not UserRef: its profile carries the badge that setUserBadge just stored
@Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR()
@Serializable @SerialName("storeReceiptNotRecorded") class StoreReceiptNotRecorded(val user: UserRef): CR()
@Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List<OpenStorePurchase> = emptyList()): CR()
@Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List<StatementEntry>): CR()
@Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR()
@@ -7165,6 +7188,7 @@ sealed class CR {
is AppSettingsR -> "appSettings"
is BadgeRedeemed -> "badgeRedeemed"
is BadgeInvoice -> "badgeInvoice"
is StoreReceiptNotRecorded -> "storeReceiptNotRecorded"
is BadgeStateR -> "badgeState"
is BadgeLedger -> "badgeLedger"
is BadgeChanged -> "badgeChanged"
@@ -7376,6 +7400,7 @@ sealed class CR {
is AppSettingsR -> json.encodeToString(appSettings)
is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}")
is BadgeInvoice -> withUser(user, invoiceId)
is StoreReceiptNotRecorded -> withUser(user, noDetails())
is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}")
is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger))
is BadgeChanged -> withUser(user, json.encodeToString(badgeState))
@@ -7440,6 +7465,7 @@ sealed class BadgeRedeemError {
is InvalidReceipt -> "invalidReceipt"
is ServiceNotConfigured -> "serviceNotConfigured"
is BadgeActive -> "badgeActive"
is StorePurchaseOpen -> "storePurchaseOpen"
is ServiceError -> "serviceError ${serviceError.text}"
is InvalidResponse -> "invalidResponse $message"
is UnknownKeyIndex -> "unknownKeyIndex"
@@ -7449,6 +7475,7 @@ sealed class BadgeRedeemError {
@Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError()
@Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError()
@Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError()
@Serializable @SerialName("storePurchaseOpen") object StorePurchaseOpen : BadgeRedeemError()
@Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError()
@Serializable @SerialName("invalidResponse") class InvalidResponse(val message: String) : BadgeRedeemError()
@Serializable @SerialName("unknownKeyIndex") object UnknownKeyIndex : BadgeRedeemError()
@@ -48,6 +48,7 @@ interface PlatformInterface {
suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {}
suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {}
suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
fun androidManageBadgeSubscription(productId: String) {}
val androidApiLevel: Int? get() = null
// The build distributed via Google Play, which has to follow its policies
val androidIsPlayStoreBuild: Boolean get() = false
@@ -42,16 +42,12 @@ val badgeStoreProductIds: List<BadgeStoreProductId> = BadgeLevel.entries.flatMap
BadgePeriod.entries.map { badgeStoreProductId(level, it) }
}
// the only products sent to the badge service: nothing delivers a subscription yet
val badgeProductIds: Set<String> = badgeStoreProductIds.map { it.productId }.toSet()
// a one-time product is acknowledged when core holds it and consumed when finished; a subscription is
// neither, as the service acknowledges it when it credits it, and Play refunds it if it never does
val badgeOneTimeProductIds: Set<String> = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet()
// subscriptions are for sale once renewals are delivered
val badgePeriodsForSale: List<BadgePeriod> = listOf(BadgePeriod.OneMonth)
// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core.
// Sent to Play as obfuscatedAccountId and echoed back on the purchase.
fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
// the page's app flag rides in the fragment, which never reaches the service
// TEST ONLY: pointed at the dev deployment; restore the two lines below before merging
fun badgePageUrl(linkReturns: Boolean): String =
@@ -95,7 +91,9 @@ data class BadgeStoreReceipt(
val token: String,
val productId: String,
val invoiceId: String?,
val acknowledged: Boolean = false
val acknowledged: Boolean = false,
// a subscription's, as Play lists it; null for a one-time product
val autoRenewing: Boolean? = null
)
// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens
@@ -191,6 +189,10 @@ object BadgeStore {
return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList()
}
// Play gives a subscription bought again a new token, and refuses to sell one the account still has
@Suppress("UNUSED_PARAMETER")
fun forSale(period: BadgePeriod): Boolean = true
fun price(level: BadgeLevel, period: BadgePeriod, compact: Boolean = true): BadgePrice = when (state.value) {
LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading
LoadState.Loaded, LoadState.Failed -> {
@@ -230,16 +232,10 @@ object BadgeStore {
}
}
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
// A purchase has its core record before the store charges, and every store outcome reaches it.
suspend fun purchase(level: BadgeLevel, period: BadgePeriod): BadgePurchaseOutcome {
val id = badgeStoreProductId(level, period)
if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId)
// a subscription is never sent to core, so nothing would ever finish it later
if (id.productId !in badgeOneTimeProductIds) {
val outcome = storePurchase(id, newBadgeInvoiceId())
if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt)
return outcome
}
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile
val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId)
@@ -248,7 +244,7 @@ object BadgeStore {
try {
val outcome = storePurchase(id, invoiceId)
if (outcome is BadgePurchaseOutcome.Purchased) {
handOver(outcome.receipt)
handOver(outcome.receipt, listed = false)
}
return outcome
} finally {
@@ -260,7 +256,8 @@ object BadgeStore {
val outcome = if (useBadgeTestProducts) {
BadgePurchaseOutcome.Purchased(
BadgeStoreReceipt(
token = "test-${UUID.randomUUID()}",
// the dev service's mock reads a subscription's plan from its token, as Play's real ones name none
token = if (id.basePlanId != null) "test.${id.basePlanId}.${UUID.randomUUID()}" else "test-${UUID.randomUUID()}",
productId = id.productId,
invoiceId = invoiceId
)
@@ -274,13 +271,14 @@ object BadgeStore {
// Core holds the receipt and credits it; the purchase stays unfinished until core answers it credited
// or refused, as an unfinished purchase is what the store re-delivers if anything is lost on the way.
private suspend fun handOver(receipt: BadgeStoreReceipt) {
private suspend fun handOver(receipt: BadgeStoreReceipt, listed: Boolean) {
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: return
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token))) {
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, listed, ServicePayment.Google(receipt.productId, receipt.token))) {
is BadgePurchaseResult.Held -> {
// core holds it durably now, so Play must not refund it after three days unacknowledged
acknowledge(receipt)
// core holds it durably now, so Play must not refund it after three days unacknowledged;
// a subscription is acknowledged by the service when it credits it
if (receipt.productId in badgeOneTimeProductIds) acknowledge(receipt)
withContext(Dispatchers.Main) {
// the answer is the owner's, which may be another profile and a hidden one
if (chatModel.controller.activeUser(rhId, r.user)) {
@@ -299,6 +297,9 @@ object BadgeStore {
}
finish(receipt)
}
is BadgePurchaseResult.NotRecorded -> {
// left unfinished, for the device that bought it, or for Restore to claim it here
}
// a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer
is BadgePurchaseResult.Failed ->
if (badgeReceiptRefused(r.err)) {
@@ -358,45 +359,116 @@ object BadgeStore {
val purchases = platform.androidUnfinishedBadgePurchases()
// Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here
withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() }
purchases.forEach { reconcile(it) }
purchases.forEach { reconcileListed(it) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}")
}
}
reportSubscriptionStates()
chatModel.controller.loadBadgeState(chatModel.remoteHostId())
} finally {
withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true }
}
}
suspend fun reconcile(outcome: BadgePurchaseOutcome) {
// Play lists a Google account's purchases on every device signed into it, so these are handed over as listed
suspend fun reconcileListed(outcome: BadgePurchaseOutcome) {
// the purchase stays unfinished, so the next sweep hands it over again
try {
reconcile(outcome, listed = true)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}")
withContext(Dispatchers.Main) {
if (!sweepFailureShown) {
sweepFailureShown = true
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_error),
text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true)
else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}"
)
}
}
}
}
private suspend fun reconcile(outcome: BadgePurchaseOutcome, listed: Boolean) {
when (outcome) {
is BadgePurchaseOutcome.Purchased ->
if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt)
// the purchase stays unfinished, so the next sweep hands it over again
else try {
handOver(outcome.receipt)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}")
withContext(Dispatchers.Main) {
if (!sweepFailureShown) {
sweepFailureShown = true
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_error),
text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true)
else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}"
)
}
}
}
if (outcome.receipt.productId !in badgeProductIds) finish(outcome.receipt)
else handOver(outcome.receipt, listed)
is BadgePurchaseOutcome.Pending ->
if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId }
is BadgePurchaseOutcome.Cancelled -> {}
}
}
// The service asks the store only once the paid period ends, so a cancellation or a failed charge before
// then is seen only here. Play lists no subscription on hold or expired, so it tells renewing or cancelled only.
suspend fun reportSubscriptionStates() {
// the store's purchases are this device's, so they are told to its own core
if (chatModel.remoteHostId() != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return
try {
for (outcome in platform.androidUnfinishedBadgePurchases()) {
val receipt = (outcome as? BadgePurchaseOutcome.Purchased)?.receipt ?: continue
val state = when (receipt.autoRenewing) {
true -> BadgeSubscriptionState.Renewing
false -> BadgeSubscriptionState.Cancelled
null -> null
}
if (state != null) {
chatModel.controller.apiSetBadgeSubscriptionStatus(null, state, ServicePayment.Google(receipt.productId, receipt.token))
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.reportSubscriptionStates: ${e.message}")
}
}
// Play's page opens over the app, so the foreground sweep re-reads the subscription on return
fun manageSubscriptions(badgeType: BadgeType) {
val level = BadgeLevel.entries.firstOrNull { it.badgeType == badgeType } ?: return
platform.androidManageBadgeSubscription(badgeStoreProductId(level, BadgePeriod.Monthly).productId)
}
// Hands over as the device's own whatever the store lists, so a purchase no record here has is claimed by this profile.
// Answers whether a badge is shown or a store purchase is open afterwards.
suspend fun restorePurchases(): Boolean {
var handOverError: Exception? = null
val purchases = if (useBadgeTestProducts) emptyList() else platform.androidUnfinishedBadgePurchases()
for (outcome in purchases) {
try {
reconcile(outcome, listed = false)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.restorePurchases: ${e.stackTraceToString()}")
if (handOverError == null) handOverError = e
}
}
reportSubscriptionStates()
val rhId = chatModel.remoteHostId()
chatModel.controller.loadBadgeState(rhId)
if (handOverError != null) throw handOverError
return withContext(Dispatchers.Main) {
val userId = chatModel.currentUser.value?.userId
val badgeShown = BadgeModel.isCurrent(rhId, userId) && BadgeModel.badgeState.value?.shown == true
badgeShown || openStorePurchases(userId).isNotEmpty()
}
}
fun subscriptionPrice(badgeType: BadgeType, planMonths: Int): String? {
val period = when (planMonths) {
1 -> BadgePeriod.Monthly
12 -> BadgePeriod.Annual
else -> null
}
val level = BadgeLevel.entries.firstOrNull { it.badgeType == badgeType }
if (period == null || level == null) return null
return products.value[badgeStoreProductId(level, period)]?.displayPrice
}
private suspend fun acknowledge(receipt: BadgeStoreReceipt) {
try {
if (!useBadgeTestProducts) platform.androidAcknowledgeBadgePurchase(receipt)
@@ -75,7 +75,7 @@ enum class BadgePeriod {
@Composable
fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) {
var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) }
var selectedPeriod by remember { mutableStateOf(BadgePeriod.OneMonth) }
LaunchedEffect(Unit) { BadgeStore.load() }
CloseWhenSupportGivesWay(modalManager, unwindToDepth)
@@ -145,7 +145,7 @@ fun BadgeBillingFooter(period: BadgePeriod) {
@Composable
private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
val isSelected = period == selectedPeriod
val forSale = period in badgePeriodsForSale
val forSale = BadgeStore.forSale(period)
val textColor = if (forSale) Color.Unspecified else MaterialTheme.colors.secondary
val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f)
// Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast.
@@ -1076,7 +1076,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
val alert = BadgeModel.alert.value
if (supportEnded() && alert != null) {
item {
SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeExpired }
SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeAlert }
Box(Modifier.zIndex(1f).padding(16.dp)) {
SupportSimpleXBanner(
title = stringResource(MR.strings.badges_support_ended),
@@ -1088,7 +1088,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
} else if (badgeIssueFailed()) {
item {
SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeIssueFailed }
SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeAlert }
Box(Modifier.zIndex(1f).padding(16.dp)) {
SupportSimpleXBanner(
title = stringResource(MR.strings.badges_renewal_failed),
@@ -3219,6 +3219,8 @@
<string name="badges_error_service_not_configured_purchase">This app version cannot buy badges.</string>
<string name="badges_error_already_active">This profile already has a badge. Redeem the code on another profile, or once this badge ends.</string>
<string name="badges_error_already_active_purchase">This profile already has a badge.</string>
<string name="badges_error_store_purchase_open">A store purchase is being added to this profile. Redeem the code once it is added, or on another profile.</string>
<string name="badges_error_store_purchase_open_purchase">A store purchase is being added to this profile.</string>
<string name="badges_error_code_invalid">This code was not recognized.</string>
<string name="badges_error_code_used">This code has already been used.</string>
<string name="badges_error_code_expired">This code has expired.</string>