mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-06 21:18:01 +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() {
|
||||
|
||||
+82
-10
@@ -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 {
|
||||
|
||||
+39
-12
@@ -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()
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+114
-42
@@ -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)
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user