mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-15 00:30:00 +00:00
ios: badges app store integration
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = "<group>"; };
|
||||
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
|
||||
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
|
||||
64C03BED302F413800072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = "<group>"; };
|
||||
64C03BEF302F416400072BDE /* SimpleX.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleX.storekit; sourceTree = "<group>"; };
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
|
||||
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
|
||||
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
Reference in New Issue
Block a user