mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-06 12:08:44 +00:00
ios, android: show progress while a badge is bought, and say the payment is complete while it is issued
This commit is contained in:
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
+6
-12
@@ -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 {
|
||||
|
||||
+19
-26
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user