From e33a06fc326362fc535dc621ede05e15aba09eb5 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:39 +0400 Subject: [PATCH] ios, android: show progress while a badge is bought, and say the payment is complete while it is issued --- apps/ios/Shared/Views/Badges/BadgeStore.swift | 13 ++-- .../Views/Badges/BadgesCheckOrderView.swift | 66 +++++++------------ .../Views/Badges/BadgesHowLongView.swift | 2 +- .../Badges/BadgesPurchaseStateView.swift | 2 +- .../google/java/chat/simplex/app/PlayStore.kt | 1 - .../simplex/common/views/badges/BadgeStore.kt | 18 ++--- .../views/badges/BadgesCheckOrderView.kt | 45 ++++++------- .../common/views/badges/BadgesHowLongView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 3 +- 9 files changed, 57 insertions(+), 95 deletions(-) diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index d2ce332956..300d807a20 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -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) -> 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 ) diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift index 5819319dcf..997089bac3 100644 --- a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index f918b7df33..da43418924 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -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) diff --git a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift index 4164692beb..32934e4f41 100644 --- a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift @@ -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." } 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 6d34c37168..4b332e01a3 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 @@ -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 ) ) 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 4d64a00f11..8556f908eb 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 @@ -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 { + 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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt index bec027c3e4..f7dced9587 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -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 - ) -} 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 c21a17f6ce..9d30c785ad 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 @@ -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, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 193898eede..9ae04740e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3180,14 +3180,13 @@ Get badge + better files You can support SimpleX later in Settings. Support SimpleX to send larger files that stay available longer - Purchase successful Purchase error The purchase will be retried, and the badge will arrive. Check your order Duration Total Issuing your badge - The store has taken the payment. Keeps trying if you close the app. + Your payment is complete. The badge will be added to this profile. Waiting for approval Nothing has been charged. Checking your purchase