mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-12 02:57:56 +00:00
core, badge-service, ios, android: change badge tier in the store, and restore a subscription on another device (#7795)
This commit is contained in:
1 parent
f8f64a3047
commit
1bbbad99cb
39 files changed
+1937
-320
No files matched your search
+23
-6
@@ -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)
|
||||
}
|
||||
|
||||
+17
-13
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user