From 9c845222e4a528d229ef0b8b90f36f1d97805296 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:12:09 +0000 Subject: [PATCH] ui: badges app store integration (#7374) --- apps/ios/BadgeTestProducts.storekit | 230 ++++++++++++++++++ apps/ios/Shared/Views/Badges/BadgeStore.swift | 183 ++++++++++++++ .../Shared/Views/Badges/BadgesPayView.swift | 149 +++++++++++- .../Badges/BadgesSupportSimplexView.swift | 2 + .../Views/Badges/BadgesYourLevelView.swift | 18 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 78 +++--- .../foss/java/chat/simplex/app/PlayStore.kt | 18 ++ .../google/java/chat/simplex/app/PlayStore.kt | 190 +++++++++++++++ .../main/java/chat/simplex/app/SimplexApp.kt | 7 + .../chat/simplex/common/platform/Platform.kt | 12 + .../simplex/common/views/badges/BadgeStore.kt | 187 ++++++++++++++ .../common/views/badges/BadgesPayView.kt | 143 +++++++++-- .../views/badges/BadgesSupportSimplexView.kt | 3 + .../views/badges/BadgesYourLevelView.kt | 10 +- .../commonMain/resources/MR/base/strings.xml | 12 +- 15 files changed, 1157 insertions(+), 85 deletions(-) create mode 100644 apps/ios/BadgeTestProducts.storekit create mode 100644 apps/ios/Shared/Views/Badges/BadgeStore.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt diff --git a/apps/ios/BadgeTestProducts.storekit b/apps/ios/BadgeTestProducts.storekit new file mode 100644 index 0000000000..f32c2c8dc3 --- /dev/null +++ b/apps/ios/BadgeTestProducts.storekit @@ -0,0 +1,230 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "B4D6E1A0", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "7.00", + "familyShareable" : false, + "internalID" : "3010000001", + "localizations" : [ + { + "description" : "Supporter badge for one month", + "displayName" : "SimpleX Supporter Badge", + "locale" : "en_US" + } + ], + "productID" : "BADGE_SUPPORTER_01", + "referenceName" : "SimpleX Supporter Badge", + "type" : "Consumable" + }, + { + "displayPrice" : "70.00", + "familyShareable" : false, + "internalID" : "3010000002", + "localizations" : [ + { + "description" : "Legend badge for one month", + "displayName" : "SimpleX Legend Badge", + "locale" : "en_US" + } + ], + "productID" : "BADGE_LEGEND_01", + "referenceName" : "SimpleX Legend Badge", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "", + "_developerTeamID" : "", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 0, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + { + "id" : "3020000001", + "localizations" : [ + + ], + "name" : "SimpleX Badge Subscription", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "7.00", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "3020000011", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Supporter badge, renews monthly", + "displayName" : "SimpleX Supporter Badge (Month)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_SUPPORTER_MONTH_01", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "SimpleX Supporter Badge (Month)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "42.00", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "3020000012", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Supporter badge, renews yearly", + "displayName" : "SimpleX Supporter Badge (Year)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_SUPPORTER_YEAR_01", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "SimpleX Supporter Badge (Year)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "70.00", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "3020000013", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Legend badge, renews monthly", + "displayName" : "SimpleX Legend Badge (Month)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_LEGEND_MONTH_01", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "SimpleX Legend Badge (Month)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "420.00", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "3020000014", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Legend badge, renews yearly", + "displayName" : "SimpleX Legend Badge (Year)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_LEGEND_YEAR_01", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "SimpleX Legend Badge (Year)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift new file mode 100644 index 0000000000..8d092bbc19 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -0,0 +1,183 @@ +// +// BadgeStore.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 14.08.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import Foundation +import Combine +import StoreKit + +// TODO [badges] product ids will come from app config and prices from the badge service catalog; +// hardcoded here so the App Store integration can be tested before the purchase API lands. +func badgeProductId(_ level: BadgeLevel, _ period: BadgePeriod) -> String { + switch (level, period) { + case (.supporter, .oneMonth): "BADGE_SUPPORTER_01" + case (.supporter, .monthly): "SUBSCR_BADGE_SUPPORTER_MONTH_01" + case (.supporter, .annual): "SUBSCR_BADGE_SUPPORTER_YEAR_01" + case (.legend, .oneMonth): "BADGE_LEGEND_01" + case (.legend, .monthly): "SUBSCR_BADGE_LEGEND_MONTH_01" + case (.legend, .annual): "SUBSCR_BADGE_LEGEND_YEAR_01" + } +} + +let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in + BadgePeriod.allCases.map { badgeProductId(level, $0) } +} + +// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. +// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction, +// which is how the service learns which invoice a store transaction settles. +func newBadgeInvoiceId() -> UUID { UUID() } + +enum BadgePrice { + case loading + case price(String) + case unavailable + + var canPurchase: Bool { + switch self { + case .price: true + case .loading, .unavailable: false + } + } +} + +struct BadgeStoreReceipt { + // the signed token the badge service verifies - never transaction.jsonRepresentation + let jws: String + let productId: String + let transactionId: UInt64 + let invoiceId: UUID? + let environment: String? + let signatureVerified: Bool +} + +enum BadgePurchaseOutcome { + case purchased(BadgeStoreReceipt) + case pending + case cancelled +} + +enum BadgeStoreError: Error { + case productUnavailable(productId: String) + case unknownPurchaseResult +} + +final class BadgeStore: ObservableObject { + static let shared = BadgeStore() + + private enum LoadState { case notLoaded, loading, loaded, failed } + + @Published private var state: LoadState = .notLoaded + private var products: [String: Product] = [:] + + private init() {} + + func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice { + switch state { + case .notLoaded, .loading: return .loading + case .loaded, .failed: + if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) } + return .unavailable + } + } + + // percentage the annual subscription saves against 12 monthly payments + func annualSavings(_ level: BadgeLevel) -> Int? { + guard let monthly = products[badgeProductId(level, .monthly)], + let annual = products[badgeProductId(level, .annual)] + else { return nil } + let year = monthly.price * 12 + guard year > 0, annual.price < year else { return nil } + let saved = (year - annual.price) / year * 100 + let percent = Int(NSDecimalNumber(decimal: saved).doubleValue.rounded()) + return percent > 0 ? percent : nil + } + + func load() async { + guard await startLoading() else { return } + do { + let loaded = try await Product.products(for: badgeProductIds) + let byId = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { p, _ in p }) + let missing = badgeProductIds.filter { byId[$0] == nil } + if !missing.isEmpty { + // the store drops ids it cannot resolve without saying why, so the storefront and + // the bundle the request was made for are logged with them + let storefront = await Storefront.current + logger.warning("BadgeStore.load: no product returned for \(missing.joined(separator: ", ")) - bundle \(Bundle.main.bundleIdentifier ?? "?"), storefront \(storefront?.countryCode ?? "none"), canMakePayments \(AppStore.canMakePayments)") + } + await MainActor.run { + products = byId + state = .loaded + } + } catch let error { + logger.error("BadgeStore.load: \(String(describing: error))") + await MainActor.run { state = .failed } + } + } + + func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome { + let productId = badgeProductId(level, period) + guard let product = await MainActor.run(body: { products[productId] }) else { + throw BadgeStoreError.productUnavailable(productId: productId) + } + switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { + case let .success(verification): + let transaction: Transaction + let signatureVerified: Bool + switch verification { + case let .verified(t): + transaction = t + signatureVerified = true + case let .unverified(t, _): + transaction = t + signatureVerified = false + } + // nothing is delivered in this build, so the transaction is finished right away; once the + // service issues credentials it must only be finished after the credential is stored + await transaction.finish() + return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified)) + case .pending: return .pending + case .userCancelled: return .cancelled + @unknown default: throw BadgeStoreError.unknownPurchaseResult + } + } + + @MainActor + private func startLoading() -> Bool { + switch state { + case .notLoaded, .failed: + state = .loading + return true + case .loading, .loaded: + return false + } + } +} + +// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency style; +// Product.displayPrice remains the exact form for views that need the cents +private func compactPrice(_ product: Product) -> String { + var whole = Decimal() + var price = product.price + NSDecimalRound(&whole, &price, 0, .plain) + return whole == product.price + ? product.price.formatted(product.priceFormatStyle.precision(.fractionLength(0))) + : product.displayPrice +} + +private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt { + var environment: String? = nil + if #available(iOS 16.0, *) { environment = t.environment.rawValue } + return BadgeStoreReceipt( + jws: jws, + productId: t.productID, + transactionId: t.id, + invoiceId: t.appAccountToken, + environment: environment, + signatureVerified: signatureVerified + ) +} diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesPayView.swift index b32c14485f..707bdb3059 100644 --- a/apps/ios/Shared/Views/Badges/BadgesPayView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesPayView.swift @@ -12,29 +12,62 @@ import SimpleXChat // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum BadgePeriod: String, CaseIterable, Identifiable { case oneMonth - case subscribe + case monthly + case annual var id: String { rawValue } var icon: String { switch self { case .oneMonth: "calendar" - case .subscribe: "arrow.clockwise" + case .monthly: "arrow.clockwise" + case .annual: "arrow.clockwise" } } var label: LocalizedStringKey { switch self { case .oneMonth: "1 month" - case .subscribe: "Subscribe" + case .monthly: "Monthly" + case .annual: "Annual" + } + } + + func priceText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text(verbatim: "…") + case .unavailable: return Text(verbatim: "—") + case let .price(p): + switch self { + case .oneMonth: return Text(verbatim: p) + case .monthly: return Text("\(p)/month") + case .annual: return Text("\(p)/year") + } + } + } + + func payText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text("Loading…") + case .unavailable: return Text("Not available") + case let .price(p): + switch self { + case .oneMonth: return Text("Pay \(p)") + case .monthly: return Text("Pay \(p)/month") + case .annual: return Text("Pay \(p)/year") + } } } } struct BadgesPayView: View { @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared let level: BadgeLevel - @State private var selectedPeriod: BadgePeriod = .subscribe + @State private var selectedPeriod: BadgePeriod = .monthly + @State private var purchasing = false + // presented from this view, not AlertManager: its host is behind the sheet these views open in + @State private var alert: SomeAlert? var body: some View { GeometryReader { g in @@ -59,10 +92,14 @@ struct BadgesPayView: View { Spacer(minLength: 20) + // fixedSize + maxHeight on the cards so all three match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts HStack(alignment: .top, spacing: 12) { periodCard(.oneMonth) - periodCard(.subscribe) + periodCard(.monthly) + periodCard(.annual) } + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 20) @@ -86,6 +123,8 @@ struct BadgesPayView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + .alert(item: $alert) { $0.alert } } private func periodCard(_ period: BadgePeriod) -> some View { @@ -99,13 +138,21 @@ struct BadgesPayView: View { .scaledToFit() .frame(width: 32, height: 32) .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) - .padding(.top, 30) Text(period.label) .font(.title3) .fontWeight(.bold) - .padding(.bottom, 30) + period.priceText(store.price(level, period)) + .font(.body) + if let percent = savingsPercent(period) { + Text("Save \(percent)%") + .font(.footnote) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + } } - .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.vertical, 25) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( @@ -116,13 +163,89 @@ struct BadgesPayView: View { .buttonStyle(.plain) } + private func savingsPercent(_ period: BadgePeriod) -> Int? { + period == .annual ? store.annualSavings(level) : nil + } + private func payButton() -> some View { - Button { - // TODO [badges] wire to purchase API when it lands. + let price = store.price(level, selectedPeriod) + let disabled = !price.canPurchase || purchasing + return Button { + purchase() } label: { - Text(selectedPeriod == .subscribe ? "Pay \(level.priceAmount)/month" : "Pay \(level.priceAmount)") + selectedPeriod.payText(price) } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func purchase() { + let period = selectedPeriod + let invoiceId = newBadgeInvoiceId() + purchasing = true + Task { + do { + let outcome = try await store.purchase(level, period, invoiceId: invoiceId) + await MainActor.run { + purchasing = false + switch outcome { + case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId) + case .pending: + alert = SomeAlert( + alert: mkAlert( + title: "Purchase pending", + message: "The purchase is awaiting approval. This build does not deliver purchases approved later." + ), + id: "badgePurchasePending" + ) + case .cancelled: break + } + } + } catch let error { + logger.error("BadgesPayView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + alert = SomeAlert( + alert: Alert( + title: Text("Purchase error"), + message: Text(verbatim: String(describing: error)) + ), + id: "badgePurchaseError" + ) + } + } + } + } + + // TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. + 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")") + lines.append("Token: \(receipt.jws.count) bytes") + 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), + primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws }, + secondaryButton: .cancel(Text("Ok")) + ), + id: "badgePurchased" + ) } private var billingFooter: LocalizedStringKey { @@ -131,7 +254,7 @@ struct BadgesPayView: View { let stubDate = Calendar.current.date(from: comps) ?? Date() let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) switch selectedPeriod { - case .subscribe: return "Renews on \(date). Cancel anytime." + case .monthly, .annual: return "Renews on \(date). Cancel anytime." case .oneMonth: return "Ends on \(date)." } } diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 6ae3176e85..2538d874a1 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -62,6 +62,8 @@ struct BadgesSupportSimplexView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) + // preloaded here so the level screen shows store prices without a placeholder pass + .task { await BadgeStore.shared.load() } } private func whyBuiltButton() -> some View { diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift index 3cfd576ea9..eb2396c655 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift @@ -30,13 +30,6 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } - var priceAmount: String { - switch self { - case .supporter: "$7" - case .legend: "$70" - } - } - var tagline: LocalizedStringKey { switch self { case .supporter: "Optional profile badge\nand 2GB files" @@ -54,6 +47,7 @@ enum BadgeLevel: String, CaseIterable, Identifiable { struct BadgesYourLevelView: View { @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared @State private var selectedLevel: BadgeLevel = .supporter @State private var continueActive = false @State private var howItWorksActive = false @@ -78,10 +72,13 @@ struct BadgesYourLevelView: View { Spacer(minLength: 20) + // fixedSize + maxHeight on the cards so both match the taller one when a + // store price wraps in one of them HStack(alignment: .top, spacing: 12) { levelCard(.supporter) levelCard(.legend) } + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 20) @@ -101,6 +98,7 @@ struct BadgesYourLevelView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } } private func levelCard(_ level: BadgeLevel) -> some View { @@ -120,11 +118,13 @@ struct BadgesYourLevelView: View { Text(level.filesDescription) .font(.subheadline) .foregroundColor(theme.colors.secondary) - Text("\(level.priceAmount)/month") + BadgePeriod.monthly.priceText(store.price(level, .monthly)) .font(.body) .padding(.bottom, 20) } - .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 211b53e4f7..a9a1fa180e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -145,13 +145,6 @@ 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; }; 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; }; 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; - 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */; }; - 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */; }; - 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */; }; - 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */; }; - 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */; }; - 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */; }; - 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FB3020A5AD0056E083 /* BadgesPayView.swift */; }; 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; }; 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; @@ -186,6 +179,14 @@ 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; }; + 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; }; + 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; }; + 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; }; + 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; }; + 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; }; + 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; }; + 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; @@ -531,15 +532,9 @@ 640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; 640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = ""; }; 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; - 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; - 641377FB3020A5AD0056E083 /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; - 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; - 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; - 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; - 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; - 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; 642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = ""; }; + 642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = BadgeTestProducts.storekit; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = ""; }; 6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = ""; }; @@ -573,6 +568,14 @@ 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; + 64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; + 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; + 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; + 64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = ""; }; + 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; + 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; + 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; @@ -786,6 +789,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 64C03BF8302F423300072BDE /* Badges */, B76E6C2F2C5C41C300EC11AA /* Contacts */, 5CB0BA8C282711BC00B3292C /* Onboarding */, 3C714775281C080100CB4D4B /* Call */, @@ -798,7 +802,6 @@ 8C7D94982B8894D300B7B9E1 /* Migration */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, - E5BADE0100000000BADE0000 /* Badges */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); path = Views; @@ -907,6 +910,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */, E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */, E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */, 5C55A92D283D0FDE00C4E99E /* sounds */, @@ -1201,6 +1205,21 @@ path = Group; sourceTree = ""; }; + 64C03BF8302F423300072BDE /* Badges */ = { + isa = PBXGroup; + children = ( + 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, + 64C03BF1302F423300072BDE /* BadgesPayView.swift */, + 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */, + 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */, + 64C03BF4302F423300072BDE /* BadgeStore.swift */, + 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */, + 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */, + 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */, + ); + path = Badges; + sourceTree = ""; + }; 8C74C3E92C1B909200039E77 /* Theme */ = { isa = PBXGroup; children = ( @@ -1253,20 +1272,6 @@ path = "SimpleX SE"; sourceTree = ""; }; - E5BADE0100000000BADE0000 /* Badges */ = { - isa = PBXGroup; - children = ( - 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */, - 641377FB3020A5AD0056E083 /* BadgesPayView.swift */, - 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */, - 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */, - 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */, - 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */, - 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */, - ); - path = Badges; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1631,6 +1636,14 @@ 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, + 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */, + 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */, + 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */, + 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */, + 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */, + 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */, + 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */, + 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */, 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, @@ -1722,13 +1735,6 @@ 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, - 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */, - 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */, - 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */, - 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */, - 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */, - 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */, - 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */, 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 181fe42389..bf4a3fb71d 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 @@ -1,4 +1,22 @@ package chat.simplex.app +import chat.simplex.common.platform.Log +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreError +import chat.simplex.common.views.badges.BadgeStoreProductId + // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} + +// No store in this flavor: no product is offered, so the purchase screen shows nothing to buy +// TODO [badges] this build pays via Stripe/crypto - the badge service catalog replaces these +@Suppress("UNUSED_PARAMETER") +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + Log.w(TAG, "loadBadgeProducts: no store in the foss build, use the google variant to buy from Play") + return emptyList() +} + +@Suppress("UNUSED_PARAMETER") +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = + throw BadgeStoreError.StoreUnavailable 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 a0e7734ff0..14e8fc470c 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,8 +1,14 @@ package chat.simplex.app +import chat.simplex.common.platform.Log import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry +import chat.simplex.common.platform.mainActivity +import chat.simplex.common.views.badges.* import com.android.billingclient.api.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext // Requests the country of the Google Play account into [androidPlayStoreCountry]. // It stays null when Play is unavailable or the user is not signed in. @@ -29,3 +35,187 @@ fun loadPlayStoreCountry() { override fun onBillingServiceDisconnected() = client.endConnection() }) } + +// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the +// purchase is launched, and the purchase result arrives on its listener rather than as a return value. +// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher +@Volatile private var badgeBillingClient: BillingClient? = null +@Volatile private var badgeOffers: Map = emptyMap() +@Volatile private var badgePurchase: CompletableDeferred? = null + +// offerToken is null for one-time products, which have no base plan to choose +private class BadgeOffer( + val id: BadgeStoreProductId, + val product: BadgeProduct, + val details: ProductDetails, + val offerToken: String? +) + +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + val client = connectedBadgeBillingClient() + val details = queryBadgeProducts(client, oneTimeIds, BillingClient.ProductType.INAPP) + + queryBadgeProducts(client, subscriptionIds, BillingClient.ProductType.SUBS) + val detailsByProductId = details.associateBy { it.productId } + val ids = oneTimeIds + subscriptionIds + val offers = ids.mapNotNull { id -> + val productDetails = detailsByProductId[id.productId] + if (productDetails == null) { + Log.w(TAG, "loadBadgeProducts: Play returned no product ${id.productId}") + null + } else { + productDetails.badgeOffer(id) + } + } + if (offers.size < ids.size) { + // a debug build's applicationIdSuffix is a common cause of Play not knowing the package + Log.w(TAG, "loadBadgeProducts: ${offers.size} of ${ids.size} resolved - package ${androidAppContext.packageName}, country ${androidPlayStoreCountry.value ?: "none"}") + } + badgeOffers = offers.associateBy { it.id } + return offers.map { it.product } +} + +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { + val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable + val client = connectedBadgeBillingClient() + val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) + val details = offer.details + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + offer.offerToken?.let { productParams.setOfferToken(it) } + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productParams.build())) + .setObfuscatedAccountId(invoiceId) + .build() + val purchase = CompletableDeferred() + badgePurchase = purchase + try { + val launched = withContext(Dispatchers.Main) { client.launchBillingFlow(activity, params) } + if (launched.responseCode != BillingClient.BillingResponseCode.OK) { + throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) + } + val outcome = purchase.await() + if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) + return outcome + } finally { + badgePurchase = null + } +} + +private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> + val pending = badgePurchase ?: return@PurchasesUpdatedListener + when { + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> + pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> + pending.complete(BadgePurchaseOutcome.Cancelled) + else -> + pending.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } +} + +private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { + val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled + if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending + return BadgePurchaseOutcome.Purchased( + BadgeStoreReceipt( + token = purchase.purchaseToken, + productId = purchase.products.firstOrNull() ?: "", + orderId = purchase.orderId, + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + ) + ) +} + +private suspend fun connectedBadgeBillingClient(): BillingClient { + badgeBillingClient?.let { if (it.isReady) return it } + val client = BillingClient.newBuilder(androidAppContext) + .setListener(badgePurchasesUpdatedListener) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + val connected = CompletableDeferred() + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + connected.complete(result) + } + + override fun onBillingServiceDisconnected() { + badgeBillingClient = null + connected.complete( + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED).build() + ) + } + }) + val result = connected.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + client.endConnection() + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) + } + badgeBillingClient = client + return client +} + +private suspend fun queryBadgeProducts(client: BillingClient, ids: List, productType: String): List { + // durations of one subscription share a product id, so the same product is queried once + val requested = ids.map { it.productId }.distinct() + val params = QueryProductDetailsParams.newBuilder() + .setProductList( + requested.map { + QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build() + } + ) + .build() + val queried = CompletableDeferred>() + client.queryProductDetailsAsync(params) { result, productDetailsResult -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(productDetailsResult.productDetailsList) + } else { + Log.w(TAG, "queryBadgeProducts: $productType requested $requested, failed ${result.responseCode} ${result.debugMessage}") + queried.complete(emptyList()) + } + } + return queried.await() +} + +private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { + if (id.basePlanId == null) { + val purchase = oneTimePurchaseOfferDetails + if (purchase == null) { + Log.w(TAG, "badgeOffer: ${id.productId} has no one-time purchase price, type is $productType") + return null + } + 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 } + if (offer == null) { + Log.w(TAG, "badgeOffer: ${id.productId} has no base plan ${id.basePlanId}, Play has ${subscriptionOfferDetails?.map { it.basePlanId }}") + return null + } + val phase = offer.pricingPhases.pricingPhaseList.firstOrNull { + it.recurrenceMode == ProductDetails.RecurrenceMode.INFINITE_RECURRING + } + if (phase == null) { + Log.w(TAG, "badgeOffer: ${id.productId} base plan ${id.basePlanId} has no recurring price") + return null + } + val product = BadgeProduct(id, phase.formattedPrice, phase.priceAmountMicros, phase.priceCurrencyCode) + return BadgeOffer(id, product, this, offer.offerToken) +} + +// nothing is delivered in this build, so the purchase is finished right away; once the service issues +// credentials it must only be finished after the credential is stored. One-time products are consumed +// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged +// purchase after 3 days. +private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { + val done = CompletableDeferred() + if (details.productType == BillingClient.ProductType.SUBS) { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() + client.acknowledgePurchase(params) { done.complete(it) } + } else { + val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() + client.consumeAsync(params) { result, _ -> done.complete(result) } + } + val result = done.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + } +} 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 ce47d2c5de..9826908e77 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 @@ -26,6 +26,9 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreProductId import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -343,6 +346,10 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + override suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = loadBadgeProducts(oneTimeIds, subscriptionIds) + + override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { 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 b46123c9cf..d857ab974c 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 @@ -9,6 +9,10 @@ import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatId import chat.simplex.common.model.NotificationsMode import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreError +import chat.simplex.common.views.badges.BadgeStoreProductId import kotlinx.coroutines.Job import java.io.Closeable @@ -31,6 +35,14 @@ interface PlatformInterface { fun androidIsXiaomiDevice(): Boolean = false // Requests the Google Play account country into [androidPlayStoreCountry] fun androidLoadPlayStoreCountry() {} + // Play Billing, only implemented in the google flavor + // TODO [badges] desktop and foss pay via Stripe/crypto - these defaults leave them without any + // product until that path is implemented + suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + Log.w(TAG, "androidLoadBadgeProducts: no store on this platform") + return emptyList() + } + suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable 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 new file mode 100644 index 0000000000..f8a37c39a7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -0,0 +1,187 @@ +package chat.simplex.common.views.badges + +import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.platform.* +import java.text.NumberFormat +import java.util.Currency +import java.util.UUID + +// a subscription is one store product containing a base plan per duration, so a purchasable badge +// is identified by both; one-time products have no base plan +data class BadgeStoreProductId(val productId: String, val basePlanId: String? = null) + +// TODO [badges] ids will come from app config and prices from the badge service catalog; +// hardcoded here so the Play Store integration can be tested before the purchase API lands. +fun badgeStoreProductId(level: BadgeLevel, period: BadgePeriod): BadgeStoreProductId = when (level) { + BadgeLevel.Supporter -> when (period) { + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_supporter_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-month-02") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-year-01") + } + BadgeLevel.Legend -> when (period) { + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_legend_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-month-01") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-year-01") + } +} + +val badgeStoreProductIds: List = BadgeLevel.entries.flatMap { level -> + BadgePeriod.entries.map { badgeStoreProductId(level, it) } +} + +// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service +// learns which invoice a store transaction settles. +fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() + +// what the platform store knows about one product; ProductDetails cannot cross into commonMain +data class BadgeProduct( + val id: BadgeStoreProductId, + val displayPrice: String, + val priceMicros: Long, + val currencyCode: String +) + +sealed class BadgePrice { + object Loading: BadgePrice() + class Price(val price: String): BadgePrice() + object Unavailable: BadgePrice() + + val canPurchase: Boolean + get() = when (this) { + is Price -> true + is Loading, is Unavailable -> false + } +} + +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 +) + +// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens +// and walk the purchase flow without Play Console products; the purchase is simulated and its +// receipt says so. +const val useBadgeTestProducts = false + +private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) = + BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") + +private val testBadgeProducts: List = listOf( + testProduct(BadgeLevel.Supporter, BadgePeriod.OneMonth, 7_000_000), + testProduct(BadgeLevel.Supporter, BadgePeriod.Monthly, 7_000_000), + testProduct(BadgeLevel.Supporter, BadgePeriod.Annual, 42_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.OneMonth, 70_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.Monthly, 70_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.Annual, 420_000_000) +) + +sealed class BadgePurchaseOutcome { + class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() + object Pending: BadgePurchaseOutcome() + object Cancelled: BadgePurchaseOutcome() +} + +sealed class BadgeStoreError: Exception() { + class ProductUnavailable(val productId: String): BadgeStoreError() + class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() + object StoreUnavailable: BadgeStoreError() + + override val message: String + get() = when (this) { + is ProductUnavailable -> "productUnavailable(productId: $productId)" + is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" + is StoreUnavailable -> "storeUnavailable" + } +} + +object BadgeStore { + private enum class LoadState { NotLoaded, Loading, Loaded, Failed } + + private val state = mutableStateOf(LoadState.NotLoaded) + // snapshot state so a composable reading only the products still recomposes when they arrive + private val products = mutableStateOf>(emptyMap()) + + fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { + LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading + LoadState.Loaded, LoadState.Failed -> { + val p = products.value[badgeStoreProductId(level, period)] + if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable + } + } + + fun annualSavings(level: BadgeLevel): Int? { + val monthly = products.value[badgeStoreProductId(level, BadgePeriod.Monthly)] ?: return null + val annual = products.value[badgeStoreProductId(level, BadgePeriod.Annual)] ?: return null + val year = monthly.priceMicros * 12 + if (year <= 0 || annual.priceMicros >= year) return null + val percent = Math.round((year - annual.priceMicros).toDouble() / year * 100).toInt() + return if (percent > 0) percent else null + } + + suspend fun load() { + if (!startLoading()) return + try { + // TODO [badges] desktop and the foss build will price from the badge service catalog and pay + // via Stripe/crypto instead of a store; only the google build reaches the platform store + val loaded = if (useBadgeTestProducts) testBadgeProducts else platform.androidLoadBadgeProducts( + oneTimeIds = badgeStoreProductIds.filter { it.basePlanId == null }, + subscriptionIds = badgeStoreProductIds.filter { it.basePlanId != null } + ) + val byId = loaded.associateBy { it.id } + val missing = badgeStoreProductIds.filter { !byId.containsKey(it) } + if (missing.isNotEmpty()) { + Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}") + } + products.value = byId + state.value = LoadState.Loaded + } catch (e: Exception) { + Log.e(TAG, "BadgeStore.load: ${e.stackTraceToString()}") + state.value = LoadState.Failed + } + } + + suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + val id = badgeStoreProductId(level, period) + if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) + if (useBadgeTestProducts) { + return BadgePurchaseOutcome.Purchased( + BadgeStoreReceipt( + token = "test-${UUID.randomUUID()}", + productId = id.productId, + orderId = null, + invoiceId = invoiceId, + environment = "test products" + ) + ) + } + return platform.androidPurchaseBadge(id, invoiceId) + } + + private fun startLoading(): Boolean = when (state.value) { + LoadState.NotLoaded, LoadState.Failed -> { + state.value = LoadState.Loading + true + } + LoadState.Loading, LoadState.Loaded -> false + } +} + +// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency; +// BadgeProduct.displayPrice remains the exact form for views that need the cents +private fun compactPrice(product: BadgeProduct): String { + if (product.priceMicros % 1_000_000L != 0L) return product.displayPrice + return try { + val format = NumberFormat.getCurrencyInstance() + format.currency = Currency.getInstance(product.currencyCode) + format.maximumFractionDigits = 0 + format.format(product.priceMicros / 1_000_000L) + } catch (e: Exception) { + product.displayPrice + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt index 742b03e2bd..d7a10f38f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -25,24 +28,52 @@ import chat.simplex.res.MR // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgePeriod { OneMonth, - Subscribe; + Monthly, + Annual; val icon: dev.icerock.moko.resources.ImageResource get() = when (this) { OneMonth -> MR.images.ic_calendar - Subscribe -> MR.images.ic_refresh + Monthly -> MR.images.ic_refresh + Annual -> MR.images.ic_refresh } val label: StringResource get() = when (this) { OneMonth -> MR.strings.badges_period_one_month - Subscribe -> MR.strings.badges_period_subscribe + Monthly -> MR.strings.badges_period_monthly + Annual -> MR.strings.badges_period_annual } + + @Composable + fun priceText(price: BadgePrice): String = when (price) { + is BadgePrice.Loading -> "…" + is BadgePrice.Unavailable -> "—" + is BadgePrice.Price -> when (this) { + OneMonth -> price.price + Monthly -> stringResource(MR.strings.badges_price_monthly).format(price.price) + Annual -> stringResource(MR.strings.badges_price_annual).format(price.price) + } + } + + fun payLabel(price: BadgePrice): Pair = when (price) { + is BadgePrice.Loading -> MR.strings.badges_price_loading to null + is BadgePrice.Unavailable -> MR.strings.badges_price_unavailable to null + is BadgePrice.Price -> when (this) { + OneMonth -> MR.strings.badges_pay_once to price.price + Monthly -> MR.strings.badges_pay_monthly to price.price + Annual -> MR.strings.badges_pay_annual to price.price + } + } } @Composable fun BadgesPayView(level: BadgeLevel) { - var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) } + var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } + val purchasing = remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(Unit) { BadgeStore.load() } ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -69,24 +100,25 @@ fun BadgesPayView(level: BadgeLevel) { modifier = Modifier.fillMaxWidth().padding(top = 4.dp) ) - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height - // when 2-line labels at large fonts would otherwise size them differently. + // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts. Row( Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } } - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a // TextButton so the footer has no hover/click affordance. Column(horizontalAlignment = Alignment.CenterHorizontally) { - PayButton(level, selectedPeriod) + PayButton(level, selectedPeriod, purchasing, clipboard) Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { Text( stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), @@ -101,7 +133,7 @@ fun BadgesPayView(level: BadgeLevel) { } @Composable -private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { +private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod 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. @@ -114,7 +146,7 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie .background(cardBackground, shape) .border(2.dp, borderColor, shape) .clickable { onSelect(period) } - .padding(vertical = 30.dp, horizontal = 12.dp), + .padding(vertical = 20.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -125,24 +157,97 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie modifier = Modifier.size(32.dp) ) Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + val percent = savingsPercent(level, period) + if (percent != null) { + Text( + stringResource(MR.strings.badges_savings).format("${percent}%"), + style = MaterialTheme.typography.body2, + color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } } } +private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = + if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null + +// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and +// this button stays disabled - it will offer Stripe/crypto payment instead @Composable -private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) { +private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val price = BadgeStore.price(level, selectedPeriod) + val (labelId, labelArg) = selectedPeriod.payLabel(price) OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once, - labelArg = level.priceAmount, + labelId = labelId, + labelArg = labelArg, onboarding = null, - onclick = { - // TODO [badges] wire to purchase API when it lands. + enabled = price.canPurchase && !purchasing.value, + onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val invoiceId = newBadgeInvoiceId() + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + val outcome = BadgeStore.purchase(level, period, invoiceId) + purchasing.value = false + when (outcome) { + is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) + is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_pending), + text = generalGetString(MR.strings.badges_purchase_pending_desc) + ) + is BadgePurchaseOutcome.Cancelled -> {} + } + } catch (e: Exception) { + Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = e.toString() + ) } + } +} + +// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. +private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { + 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}") + lines.add("Token: ${receipt.token.length} bytes") + val summary = lines.joinToString("\n") + Log.d(TAG, "badge purchase succeeded\n$summary") + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.badges_purchase_successful), + text = summary, + confirmText = "Copy token", + onConfirm = { + clipboard.setText(AnnotatedString(receipt.token)) + showToast(generalGetString(MR.strings.copied)) + }, + dismissText = generalGetString(MR.strings.ok), + parseHtml = false ) } private fun billingFooter(period: BadgePeriod): StringResource = when (period) { - BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe + BadgePeriod.Monthly, BadgePeriod.Annual -> MR.strings.badges_billing_footer_subscribe BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index aa8100519b..099954a4f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -33,6 +33,9 @@ import chat.simplex.res.MR @Composable fun BadgesSupportSimplexView() { // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") + // preloaded here so the level screen shows store prices without a placeholder pass + LaunchedEffect(Unit) { BadgeStore.load() } + ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt index 03082526a0..c77e4dd61e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -40,12 +40,6 @@ enum class BadgeLevel { Legend -> MR.strings.badges_level_legend_files } - val priceAmount: String - get() = when (this) { - Supporter -> "$7" - Legend -> "$70" - } - val tagline: StringResource get() = when (this) { Supporter -> MR.strings.badges_level_supporter_tagline @@ -63,6 +57,8 @@ enum class BadgeLevel { fun BadgesYourLevelView() { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } + LaunchedEffect(Unit) { BadgeStore.load() } + ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -139,7 +135,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo ) Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) - Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) } } 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 8683af1705..046d490863 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3206,12 +3206,18 @@ Send 2GB files Send 5GB files %1$s/month + %1$s/year Pay %1$s/month + Pay %1$s/year Pay %1$s + Loading… + Not available + Save %1$s Optional profile badge\nand 2GB files Optional profile badge\nand 5GB files 1 month - Subscribe + Monthly + Annual Renews on %1$s. Cancel anytime. Ends on %1$s. Your level @@ -3230,6 +3236,10 @@ Support SimpleX Get badge + files up to 5GB You can support SimpleX later in Settings. + Purchase successful + Purchase pending + The purchase is awaiting approval. This build does not deliver purchases approved later. + Purchase error Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB.