diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 0cad378da6..401b7edd07 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -31,3 +31,6 @@ suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun unfinishedBadgePurchases(): List = emptyList() + +@Suppress("UNUSED_PARAMETER") +fun manageBadgeSubscription(productId: String) {} diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 85b9da1316..6f3c22e1ae 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -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 { 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 { + val params = QueryPurchasesParams.newBuilder().setProductType(productType).build() val queried = CompletableDeferred>() client.queryPurchasesAsync(params) { result, purchases -> if (result.responseCode == BillingClient.BillingResponseCode.OK) { @@ -112,7 +122,7 @@ suspend fun unfinishedBadgePurchases(): List { 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}") } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 9093b1427b..4d05afd855 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -361,6 +361,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override suspend fun androidUnfinishedBadgePurchases(): List = unfinishedBadgePurchases() + override fun androidManageBadgeSubscription(productId: String) = manageBadgeSubscription(productId) + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 4511214cc6..6f66ae2669 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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 { + 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 { + 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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7c4acafe0b..cbb2291e93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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): 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 = emptyList()): CR() @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): 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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index f4e902b967..85b5fcd7f2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -48,6 +48,7 @@ interface PlatformInterface { suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun androidUnfinishedBadgePurchases(): List = 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index 4b37c6c7ba..f3c1661d67 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -42,16 +42,12 @@ val badgeStoreProductIds: List = 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 = 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 = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet() -// subscriptions are for sale once renewals are delivered -val badgePeriodsForSale: List = 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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index 8b58049276..67174c0596 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -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. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 0615f6e5dd..ec29738616 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1076,7 +1076,7 @@ private fun BoxScope.ChatList(searchText: MutableState, 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, 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), diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 5b0227b3a4..5d6aa227d7 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -3219,6 +3219,8 @@ This app version cannot buy badges. This profile already has a badge. Redeem the code on another profile, or once this badge ends. This profile already has a badge. + A store purchase is being added to this profile. Redeem the code once it is added, or on another profile. + A store purchase is being added to this profile. This code was not recognized. This code has already been used. This code has expired.