core, badge-service, ios, android: change badge tier in the store, and restore a subscription on another device (#7795)

This commit is contained in:
spaced4ndy authored and GitHub committed 2026-10-21 16:53:15 +00:00
1 parent f8f64a3047
commit 1bbbad99cb
39 files changed
+1937 -320

No files matched your search

@@ -595,9 +595,8 @@ object ChatController {
}
// log = false because a store receipt is a bearer secret, like a badge code - it is in the command.
// 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)
suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, origin: StoreReceiptOrigin, payment: ServicePayment): BadgePurchaseResult {
val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, origin, 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)
@@ -4173,7 +4172,7 @@ 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 listed: Boolean, val payment: ServicePayment): CC()
class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val origin: StoreReceiptOrigin, 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()
@@ -4401,7 +4400,7 @@ 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" } ?: ""}${if (listed) " listed" else ""} ${json.encodeToString(payment)}"
is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""}${storeReceiptOriginParam(origin)} ${json.encodeToString(payment)}"
is ApiSetBadgeSubscriptionStatus -> "/_badge subscription ${status.text} ${json.encodeToString(payment)}"
is ApiCreateBadgeInvoice -> "/_badge invoice $userId"
is ApiGetBadgeState -> "/_badge state $userId"
@@ -4617,7 +4616,7 @@ 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, listed, when (payment) {
is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, origin, when (payment) {
is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token))
})
is ApiSetBadgeSubscriptionStatus -> ApiSetBadgeSubscriptionStatus(status, when (payment) {
@@ -4662,6 +4661,14 @@ sealed class ServicePayment {
@Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment()
}
enum class StoreReceiptOrigin {
Purchased,
// found in the store's list rather than bought or restored here, so core records it only into a record it has
Listed,
// Restore: core claims it for this profile, moving it from another device or profile it was credited to
Restored
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
// /_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
@@ -4676,6 +4683,12 @@ private fun badgeAlertKindParam(kind: BadgeAlertKind): String = when (kind) {
is BadgeAlertKind.Unknown -> kind.kind
}
private fun storeReceiptOriginParam(origin: StoreReceiptOrigin): String = when (origin) {
StoreReceiptOrigin.Purchased -> ""
StoreReceiptOrigin.Listed -> " listed"
StoreReceiptOrigin.Restored -> " restored"
}
@Serializable
data class NewUser(
val profile: Profile?,
@@ -7515,6 +7528,7 @@ sealed class BadgeServiceErrorCode {
object ReceiptInvalid: BadgeServiceErrorCode()
object ReceiptUsed: BadgeServiceErrorCode()
object TestPurchase: BadgeServiceErrorCode()
object TransferLimited: BadgeServiceErrorCode()
object Internal: BadgeServiceErrorCode()
data class Unknown(val code: String): BadgeServiceErrorCode()
@@ -7538,6 +7552,7 @@ sealed class BadgeServiceErrorCode {
is ReceiptInvalid -> "receipt_invalid"
is ReceiptUsed -> "receipt_used"
is TestPurchase -> "test_purchase"
is TransferLimited -> "transfer_limited"
is Internal -> "internal"
is Unknown -> code
}
@@ -7554,6 +7569,7 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) {
is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid)
is BadgeServiceErrorCode.TestPurchase -> generalGetString(MR.strings.badges_error_test_purchase)
is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used)
is BadgeServiceErrorCode.TransferLimited -> generalGetString(MR.strings.badges_error_transfer_limited)
is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending)
is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable)
is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured)
@@ -7583,6 +7599,7 @@ object BadgeServiceErrorCodeSerializer : KSerializer<BadgeServiceErrorCode> {
"receipt_invalid" -> BadgeServiceErrorCode.ReceiptInvalid
"receipt_used" -> BadgeServiceErrorCode.ReceiptUsed
"test_purchase" -> BadgeServiceErrorCode.TestPurchase
"transfer_limited" -> BadgeServiceErrorCode.TransferLimited
"internal" -> BadgeServiceErrorCode.Internal
else -> BadgeServiceErrorCode.Unknown(v)
}
@@ -244,7 +244,7 @@ object BadgeStore {
try {
val outcome = storePurchase(id, invoiceId)
if (outcome is BadgePurchaseOutcome.Purchased) {
handOver(outcome.receipt, listed = false)
handOver(outcome.receipt, StoreReceiptOrigin.Purchased)
}
return outcome
} finally {
@@ -271,10 +271,10 @@ 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, listed: Boolean) {
private suspend fun handOver(receipt: BadgeStoreReceipt, origin: StoreReceiptOrigin) {
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: return
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, listed, ServicePayment.Google(receipt.productId, receipt.token))) {
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, origin, ServicePayment.Google(receipt.productId, receipt.token))) {
is BadgePurchaseResult.Held -> {
// 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
@@ -305,6 +305,8 @@ object BadgeStore {
if (badgeReceiptRefused(r.err)) {
Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}")
finish(receipt)
// a limited move raises no event, so Restore learns of it only from this answer
if (origin == StoreReceiptOrigin.Restored && r.err != null && badgeServiceErrorCode(r.err) is BadgeServiceErrorCode.TransferLimited) throw BadgeStoreError.ApiError(r.err)
} else {
throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response")
}
@@ -376,7 +378,7 @@ object BadgeStore {
suspend fun reconcileListed(outcome: BadgePurchaseOutcome) {
// the purchase stays unfinished, so the next sweep hands it over again
try {
reconcile(outcome, listed = true)
reconcile(outcome, StoreReceiptOrigin.Listed)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}")
@@ -393,11 +395,11 @@ object BadgeStore {
}
}
private suspend fun reconcile(outcome: BadgePurchaseOutcome, listed: Boolean) {
private suspend fun reconcile(outcome: BadgePurchaseOutcome, origin: StoreReceiptOrigin) {
when (outcome) {
is BadgePurchaseOutcome.Purchased ->
if (outcome.receipt.productId !in badgeProductIds) finish(outcome.receipt)
else handOver(outcome.receipt, listed)
else handOver(outcome.receipt, origin)
is BadgePurchaseOutcome.Pending ->
if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId }
is BadgePurchaseOutcome.Cancelled -> {}
@@ -436,16 +438,14 @@ object BadgeStore {
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.
// Hands over as restored whatever the store lists, so this profile claims it, also from another install or profile.
// Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards.
// Play's list does include the subscription, yet it is not restored on another install or profile: the service credited
// the payment to the purchase key of the install that bought it, and answers any other key receipt_used.
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)
reconcile(outcome, StoreReceiptOrigin.Restored)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "BadgeStore.restorePurchases: ${e.stackTraceToString()}")
@@ -518,7 +518,11 @@ private fun compactPrice(product: BadgeProduct): String {
// the codes core refuses a receipt with for good, after which the store may stop re-delivering it;
// a sweep that hands over a receipt refused while the app was not running learns it only from this answer
private fun badgeReceiptRefused(err: ChatError?): Boolean {
val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError
val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError
return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed || code is BadgeServiceErrorCode.TestPurchase
val code = badgeServiceErrorCode(err)
return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed || code is BadgeServiceErrorCode.TestPurchase || code is BadgeServiceErrorCode.TransferLimited
}
private fun badgeServiceErrorCode(err: ChatError?): BadgeServiceErrorCode? {
val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError
return (redeemError as? BadgeRedeemError.ServiceError)?.serviceError
}
@@ -3250,6 +3250,7 @@
<string name="badges_error_service_internal">The badge service reported an internal error.</string>
<string name="badges_error_receipt_invalid">The store did not confirm this purchase.</string>
<string name="badges_error_receipt_used">This purchase was already credited to a badge on another device or profile.</string>
<string name="badges_error_transfer_limited">This purchase was moved to another device or profile recently.</string>
<string name="badges_error_test_purchase">Test purchases are not credited. Badges can only be bought with a real purchase.</string>
<string name="badges_error_payment_pending">The payment has not been confirmed yet.</string>
<string name="badges_error_provider_unavailable">The store could not be reached.</string>