ios, android: show progress while a badge is bought, and say the payment is complete while it is issued

This commit is contained in:
spaced4ndy
2026-10-02 13:47:39 +04:00
parent a70ab3bac5
commit e33a06fc32
9 changed files with 57 additions and 95 deletions
@@ -66,7 +66,6 @@ struct BadgeStoreReceipt {
let productId: String
let transactionId: UInt64
let invoiceId: UUID?
let environment: String?
let signatureVerified: Bool
let transaction: Transaction
@@ -185,17 +184,16 @@ final class BadgeStore: ObservableObject {
}
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> (outcome: BadgePurchaseOutcome, invoiceId: UUID) {
func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> BadgePurchaseOutcome {
let productId = badgeProductId(level, period)
guard let product = await MainActor.run(body: { products[productId] }) else {
throw BadgeStoreError.productUnavailable(productId: productId)
}
// a subscription is never sent to core, so nothing would ever finish it later
guard badgeOneTimeProductIds.contains(productId) else {
let invoiceId = newBadgeInvoiceId()
let outcome = try await storePurchase(product, invoiceId)
let outcome = try await storePurchase(product, newBadgeInvoiceId())
if case let .purchased(receipt) = outcome { await receipt.transaction.finish() }
return (outcome, invoiceId)
return outcome
}
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else {
throw BadgeStoreError.noActiveProfile
@@ -213,7 +211,7 @@ final class BadgeStore: ObservableObject {
case .pending: break
}
await MainActor.run { _ = buying.remove(invoice) }
return (outcome, invoiceId)
return outcome
} catch let error {
await closeInvoice(userId, invoice)
await MainActor.run { _ = buying.remove(invoice) }
@@ -376,14 +374,11 @@ private func storeReceipt(_ verification: VerificationResult<Transaction>) -> Ba
case let .verified(t): (t, true)
case let .unverified(t, _): (t, false)
}
var environment: String? = nil
if #available(iOS 16.0, *) { environment = t.environment.rawValue }
return BadgeStoreReceipt(
jws: verification.jwsRepresentation,
productId: t.productID,
transactionId: t.id,
invoiceId: t.appAccountToken,
environment: environment,
signatureVerified: signatureVerified,
transaction: t
)
@@ -41,6 +41,10 @@ struct BadgesCheckOrderView: View {
.padding(.vertical, 12)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color(uiColor: .secondarySystemFill), lineWidth: 1)
)
.padding(.top, 20)
Spacer(minLength: 20)
@@ -58,6 +62,17 @@ struct BadgesCheckOrderView: View {
.frame(minHeight: g.size.height)
}
}
.overlay {
if purchasing {
ZStack {
Circle()
.fill(.white)
.opacity(0.7)
.frame(width: 56, height: 56)
ProgressView().scaleEffect(2)
}
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
@@ -94,20 +109,15 @@ struct BadgesCheckOrderView: View {
purchasing = true
Task {
do {
let (outcome, invoiceId) = try await store.purchase(level, period)
if case let .purchased(receipt) = outcome {
if !badgeOneTimeProductIds.contains(receipt.productId) {
await MainActor.run { showPurchasedAlert(receipt, invoiceId) }
} else if !receipt.signatureVerified {
await MainActor.run {
alert = SomeAlert(
alert: mkAlert(
title: "Cannot verify this purchase",
message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store."
),
id: "badgePurchaseUnverified"
)
}
if case let .purchased(receipt) = try await store.purchase(level, period), !receipt.signatureVerified {
await MainActor.run {
alert = SomeAlert(
alert: mkAlert(
title: "Cannot verify this purchase",
message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store."
),
id: "badgePurchaseUnverified"
)
}
}
await MainActor.run { purchasing = false }
@@ -126,34 +136,6 @@ struct BadgesCheckOrderView: View {
}
}
}
// TODO [badges] store integration diagnostics - replaced by the issued badge once subscriptions are delivered.
private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) {
let returnedInvoice: String
if let returned = receipt.invoiceId {
returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)"
} else {
returnedInvoice = "none"
}
var lines = [
"Product: \(receipt.productId)",
"Invoice: \(invoiceId.uuidString)",
"Invoice returned by Apple: \(returnedInvoice)",
"Transaction: \(receipt.transactionId)"
]
if let environment = receipt.environment { lines.append("Environment: \(environment)") }
lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")")
let summary = lines.joined(separator: "\n")
// logged as well as shown: the alert races StoreKit's own sheets, the log always lands
logger.debug("badge purchase succeeded\n\(summary)")
alert = SomeAlert(
alert: Alert(
title: Text("Purchase successful"),
message: Text(verbatim: summary)
),
id: "badgePurchased"
)
}
}
struct BadgesCheckOrderView_Previews: PreviewProvider {
@@ -142,7 +142,7 @@ struct BadgesHowLongView: View {
period.priceText(store.price(level, period))
.font(.title3)
.fontWeight(.semibold)
if let percent = savingsPercent(period) {
if forSale, let percent = savingsPercent(period) {
Text("Save \(percent)%")
.font(.footnote)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
@@ -50,7 +50,7 @@ struct BadgesPurchaseStateView: View {
private var message: LocalizedStringKey {
switch purchaseState {
case .issuing: "The store has taken the payment. Keeps trying if you close the app."
case .issuing: "Your payment is complete. The badge will be added to this profile."
case .waitingForApproval: "Nothing has been charged."
case .checking: "The store has not confirmed a purchase yet."
}
@@ -143,7 +143,6 @@ private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = wh
BadgeStoreReceipt(
token = purchase.purchaseToken,
productId = purchase.products.firstOrNull() ?: "",
orderId = purchase.orderId,
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId
)
)
@@ -88,10 +88,7 @@ data class BadgeStoreReceipt(
// the token the badge service verifies with the Publisher API
val token: String,
val productId: String,
val orderId: String?,
val invoiceId: String?,
// only set for test products - a real Play purchase has no environment to report
val environment: String? = null
val invoiceId: String?
)
// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens
@@ -224,15 +221,14 @@ object BadgeStore {
}
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
suspend fun purchase(level: BadgeLevel, period: BadgePeriod): Pair<BadgePurchaseOutcome, String> {
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 invoiceId = newBadgeInvoiceId()
val outcome = storePurchase(id, invoiceId)
val outcome = storePurchase(id, newBadgeInvoiceId())
if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt)
return outcome to invoiceId
return outcome
}
val rhId = chatModel.remoteHostId()
val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile
@@ -247,7 +243,7 @@ object BadgeStore {
is BadgePurchaseOutcome.Cancelled -> closeInvoice(userId, invoiceId)
is BadgePurchaseOutcome.Pending -> {}
}
return outcome to invoiceId
return outcome
} catch (e: Exception) {
closeInvoice(userId, invoiceId)
throw e
@@ -262,9 +258,7 @@ object BadgeStore {
BadgeStoreReceipt(
token = "test-${UUID.randomUUID()}",
productId = id.productId,
orderId = null,
invoiceId = invoiceId,
environment = "test products"
invoiceId = invoiceId
)
)
} else {
@@ -1,6 +1,7 @@
package chat.simplex.common.views.badges
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -48,6 +49,7 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M
.padding(top = 20.dp)
.clip(RoundedCornerShape(16.dp))
.background(sectionCardColor())
.border(1.dp, MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f), RoundedCornerShape(16.dp))
.padding(horizontal = 24.dp, vertical = 12.dp)
) {
OrderRow(MR.strings.badges_your_badge, stringResource(level.title))
@@ -63,6 +65,22 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M
BadgeBillingFooter(period)
}
}
if (purchasing.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(Modifier.size(50.dp), color = MaterialTheme.colors.background.copy(0.9f), contentColor = LocalContentColor.current, shape = RoundedCornerShape(50)){}
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
}
}
@Composable
@@ -99,10 +117,7 @@ private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: Mutable
// not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls
withLongRunningApi {
try {
val (outcome, invoiceId) = BadgeStore.purchase(level, period)
if (outcome is BadgePurchaseOutcome.Purchased && outcome.receipt.productId !in badgeOneTimeProductIds) {
showPurchasedAlert(outcome.receipt, invoiceId)
}
BadgeStore.purchase(level, period)
purchasing.value = false
} catch (e: Exception) {
Log.e(TAG, "BadgesCheckOrderView.purchase: ${e.stackTraceToString()}")
@@ -115,25 +130,3 @@ private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: Mutable
}
}
}
// TODO [badges] store integration diagnostics - replaced by the issued badge once subscriptions are delivered.
private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String) {
val returnedInvoice = when (receipt.invoiceId) {
null -> "none"
invoiceId -> "yes"
else -> "mismatch: ${receipt.invoiceId}"
}
val lines = mutableListOf(
"Product: ${receipt.productId}",
"Invoice: $invoiceId",
"Invoice returned by Google: $returnedInvoice",
"Order: ${receipt.orderId ?: "none"}"
)
if (receipt.environment != null) lines.add("Environment: ${receipt.environment}")
val summary = lines.joinToString("\n")
Log.d(TAG, "badge purchase succeeded\n$summary")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badges_purchase_successful),
text = summary
)
}
@@ -171,7 +171,7 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B
Text(stringResource(period.label), style = MaterialTheme.typography.body1, color = textColor, textAlign = TextAlign.Center)
Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.h3, fontWeight = FontWeight.SemiBold, color = textColor, textAlign = TextAlign.Center)
val percent = savingsPercent(level, period)
if (percent != null) {
if (forSale && percent != null) {
Text(
stringResource(MR.strings.badges_savings).format("${percent}%"),
style = MaterialTheme.typography.body2,
@@ -3180,14 +3180,13 @@
<string name="badges_banner_subtitle">Get badge + better files</string>
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</string>
<string name="badges_larger_files_longer">Support SimpleX to send larger files that stay available longer</string>
<string name="badges_purchase_successful">Purchase successful</string>
<string name="badges_purchase_error">Purchase error</string>
<string name="badges_purchase_will_retry">The purchase will be retried, and the badge will arrive.</string>
<string name="badges_check_your_order_title">Check your order</string>
<string name="badges_order_duration">Duration</string>
<string name="badges_order_total">Total</string>
<string name="badges_issuing_title">Issuing your badge</string>
<string name="badges_issuing_body">The store has taken the payment. Keeps trying if you close the app.</string>
<string name="badges_issuing_body">Your payment is complete. The badge will be added to this profile.</string>
<string name="badges_waiting_for_approval_title">Waiting for approval</string>
<string name="badges_waiting_for_approval_body">Nothing has been charged.</string>
<string name="badges_checking_title">Checking your purchase</string>