diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift new file mode 100644 index 0000000000..da4fe8831b --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -0,0 +1,169 @@ +// +// 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(p.displayPrice) } + 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 { + logger.warning("BadgeStore.load: no product returned for \(missing.joined(separator: ", "))") + } + 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 + } + } +} + +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..6bc7fce472 100644 --- a/apps/ios/Shared/Views/Badges/BadgesPayView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesPayView.swift @@ -12,29 +12,60 @@ 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 var body: some View { GeometryReader { g in @@ -59,10 +90,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 +121,7 @@ struct BadgesPayView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } } private func periodCard(_ period: BadgePeriod) -> some View { @@ -99,13 +135,20 @@ 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(.caption) + .foregroundColor(theme.colors.primary) + } } - .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.vertical, 30) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( @@ -116,13 +159,78 @@ 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: + showAlert( + NSLocalizedString("Purchase pending", comment: "alert title"), + message: NSLocalizedString("The purchase is awaiting approval. This build does not deliver purchases approved later.", comment: "alert message") + ) + case .cancelled: break + } + } + } catch let error { + logger.error("BadgesPayView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: String(describing: error)) + } + } + } + } + + // 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") + showAlert( + NSLocalizedString("Purchase successful", comment: "alert title"), + message: lines.joined(separator: "\n"), + actions: {[ + UIAlertAction(title: "Copy token", style: .default) { _ in + UIPasteboard.general.string = receipt.jws + }, + okAlertAction + ]} + ) } private var billingFooter: LocalizedStringKey { @@ -131,7 +239,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..153c16e391 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,12 @@ 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) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( diff --git a/apps/ios/SimpleX.storekit b/apps/ios/SimpleX.storekit new file mode 100644 index 0000000000..d31d40ab33 --- /dev/null +++ b/apps/ios/SimpleX.storekit @@ -0,0 +1,164 @@ +{ + "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" : [ + + ] + }, + "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" + }, + { + "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" + }, + { + "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" + }, + { + "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" + } + ] + } + ], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 211b53e4f7..2d997ddf45 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -186,6 +186,7 @@ 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 */; }; + 64C03BEE302F413800072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BED302F413800072BDE /* BadgeStore.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 */; }; @@ -573,6 +574,8 @@ 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 = ""; }; + 64C03BED302F413800072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = ""; }; + 64C03BEF302F416400072BDE /* SimpleX.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleX.storekit; 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 = ""; }; @@ -907,6 +910,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 64C03BEF302F416400072BDE /* SimpleX.storekit */, E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */, E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */, 5C55A92D283D0FDE00C4E99E /* sounds */, @@ -1256,6 +1260,7 @@ E5BADE0100000000BADE0000 /* Badges */ = { isa = PBXGroup; children = ( + 64C03BED302F413800072BDE /* BadgeStore.swift */, 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */, 641377FB3020A5AD0056E083 /* BadgesPayView.swift */, 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */, @@ -1548,6 +1553,7 @@ 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, 8CC317442D4FEB9B00292A20 /* EndlessScrollView.swift in Sources */, 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, + 64C03BEE302F413800072BDE /* BadgeStore.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, E5A0B0012F960000AAAA0001 /* YourNetwork.swift in Sources */,