diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift index 81e09fe9..ceace870 100644 --- a/damus/Models/Purple/DamusPurple.swift +++ b/damus/Models/Purple/DamusPurple.swift @@ -15,12 +15,15 @@ class DamusPurple: StoreObserverDelegate { @MainActor var account_cache: [Pubkey: Account] + @MainActor + var account_uuid_cache: [Pubkey: UUID] init(settings: UserSettingsStore, keypair: Keypair) { self.settings = settings self.keypair = keypair self.account_cache = [:] - self.storekit_manager = .init() + self.account_uuid_cache = [:] + self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data } // MARK: Functions @@ -96,13 +99,35 @@ class DamusPurple: StoreObserverDelegate { throw PurpleError.error_processing_response } - func create_account(pubkey: Pubkey) async throws { - let url = environment.api_base_url().appendingPathComponent("accounts") - - Log.info("Creating account with Damus Purple server", for: .damus_purple) - + func make_iap_purchase(product: Product) async throws { + let account_uuid = try await self.get_maybe_cached_uuid_for_account() + let result = try await self.storekit_manager.purchase(product: product, id: account_uuid) + switch result { + case .success(.verified(let tx)): + // Record the purchase with the storekit manager, to make sure we have the update on the UIs as soon as possible. + // During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted. + self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product)) + // Send the receipt to the server + await self.send_receipt() + default: + // Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error. + throw PurpleError.iap_purchase_error(result: result) + } + } + + @MainActor + func get_maybe_cached_uuid_for_account() async throws -> UUID { + if let account_uuid = self.account_uuid_cache[self.keypair.pubkey] { + return account_uuid + } + return try await fetch_uuid_for_account() + } + + @MainActor + func fetch_uuid_for_account() async throws -> UUID { + let url = self.environment.api_base_url().appending(path: "/accounts/\(self.keypair.pubkey)/account-uuid") let (data, response) = try await make_nip98_authenticated_request( - method: .post, + method: .get, url: url, payload: nil, payload_type: nil, @@ -112,22 +137,16 @@ class DamusPurple: StoreObserverDelegate { if let httpResponse = response as? HTTPURLResponse { switch httpResponse.statusCode { case 200: - Log.info("Created an account with Damus Purple server", for: .damus_purple) + Log.info("Got user UUID from Damus Purple server", for: .damus_purple) default: - Log.error("Error in creating account with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") + Log.error("Error in getting user UUID with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") + throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data) } } - return - } - - func create_account_if_not_existing(pubkey: Pubkey) async throws { - guard await !(self.account_exists(pubkey: pubkey) ?? false) else { return } - try await self.create_account(pubkey: pubkey) - } - - func make_iap_purchase(product: Product) async throws -> Product.PurchaseResult { - return try await self.storekit_manager.purchase(product: product) + let account_uuid_info = try JSONDecoder().decode(AccountUUIDInfo.self, from: data) + self.account_uuid_cache[self.keypair.pubkey] = account_uuid_info.account_uuid + return account_uuid_info.account_uuid } func send_receipt() async { @@ -135,19 +154,22 @@ class DamusPurple: StoreObserverDelegate { if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { - try? await create_account_if_not_existing(pubkey: keypair.pubkey) - do { let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) - let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/app-store-receipt") + let receipt_base64_string = receiptData.base64EncodedString() + let account_uuid = try await self.get_maybe_cached_uuid_for_account() + let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString] + let json_data = try JSONSerialization.data(withJSONObject: json_text) + + let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt") Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple) let (data, response) = try await make_nip98_authenticated_request( method: .post, url: url, - payload: receiptData, - payload_type: .binary, + payload: json_data, + payload_type: .json, auth_keypair: self.keypair ) @@ -270,6 +292,10 @@ extension DamusPurple { let expiry: UInt64? let active: Bool } + + fileprivate struct AccountUUIDInfo: Codable { + let account_uuid: UUID + } } // MARK: Helper structures @@ -279,6 +305,7 @@ extension DamusPurple { case translation_error(status_code: Int, response: Data) case http_response_error(status_code: Int, response: Data) case error_processing_response + case iap_purchase_error(result: Product.PurchaseResult) case translation_no_response case checkout_npub_verification_error } diff --git a/damus/Models/Purple/PurpleStoreKitManager.swift b/damus/Models/Purple/PurpleStoreKitManager.swift index a11996bc..736d6cdd 100644 --- a/damus/Models/Purple/PurpleStoreKitManager.swift +++ b/damus/Models/Purple/PurpleStoreKitManager.swift @@ -9,15 +9,40 @@ import Foundation import StoreKit extension DamusPurple { - struct StoreKitManager { - var delegate: DamusPurpleStoreKitManagerDelegate? = nil + class StoreKitManager { // Has to be a class to get around Swift-imposed limitations of mutations on concurrently executing code associated with the purchase update monitoring task. + // The delegate is any object that wants to be notified of successful purchases. (e.g. A view that needs to update its UI) + var delegate: DamusPurpleStoreKitManagerDelegate? = nil { + didSet { + // Whenever the delegate is set, send it all recorded transactions to make sure it's up to date. + Task { + Log.info("Delegate changed. Try sending all recorded valid product transactions", for: .damus_purple) + guard let new_delegate = delegate else { + Log.info("Delegate is nil. Cannot send recorded product transactions", for: .damus_purple) + return + } + Log.info("Sending all %d recorded valid product transactions", for: .damus_purple, self.recorded_purchased_products.count) + + for purchased_product in self.recorded_purchased_products { + new_delegate.product_was_purchased(product: purchased_product) + Log.info("Sent StoreKit tx to delegate", for: .damus_purple) + } + } + } + } + // Keep track of all recorded purchases so that we can send them to the delegate when it's set (whenever it's set) + var recorded_purchased_products: [PurchasedProduct] = [] + // Helper struct to keep track of a purchased product and its transaction struct PurchasedProduct { let tx: StoreKit.Transaction let product: Product } + // Singleton instance of StoreKitManager. To avoid losing purchase updates, there should only be one instance of StoreKitManager on the app. + static let standard = StoreKitManager() + init() { + Log.info("Initiliazing StoreKitManager", for: .damus_purple) self.start() } @@ -31,7 +56,17 @@ extension DamusPurple { return try await Product.products(for: DamusPurpleType.allCases.map({ $0.rawValue })) } + // Use this function to manually and immediately record a purchased product update + func record_purchased_product(_ purchased_product: PurchasedProduct) { + self.recorded_purchased_products.append(purchased_product) + self.delegate?.product_was_purchased(product: purchased_product) + } + + // This function starts a task that monitors StoreKit updates and sends them to the delegate. + // This function will run indefinitely (It should never return), so it is important to run this as a background task. private func monitor_updates() async throws { + Log.info("Monitoring StoreKit updates", for: .damus_purple) + // StoreKit.Transaction.updates is an async stream that emits updates whenever a purchase is verified. for await update in StoreKit.Transaction.updates { switch update { case .verified(let tx): @@ -42,7 +77,11 @@ extension DamusPurple { let expiration = tx.expirationDate, Date.now < expiration { - self.delegate?.product_was_purchased(product: PurchasedProduct(tx: tx, product: prod)) + Log.info("Received valid transaction update from StoreKit", for: .damus_purple) + let purchased_product = PurchasedProduct(tx: tx, product: prod) + self.recorded_purchased_products.append(purchased_product) + self.delegate?.product_was_purchased(product: purchased_product) + Log.info("Sent tx to delegate (if exists)", for: .damus_purple) } case .unverified: continue @@ -50,13 +89,17 @@ extension DamusPurple { } } - func purchase(product: Product) async throws -> Product.PurchaseResult { - return try await product.purchase(options: []) + // Use this function to complete a StoreKit purchase + // Specify the product and the app account token (UUID) to complete the purchase + // The account token is used to associate with the user's account on the server. + func purchase(product: Product, id: UUID) async throws -> Product.PurchaseResult { + return try await product.purchase(options: [.appAccountToken(id)]) } } } extension DamusPurple.StoreKitManager { + // This helper struct is used to encapsulate StoreKit products, metadata, and supplement with additional information enum DamusPurpleType: String, CaseIterable { case yearly = "purpleyearly" case monthly = "purple" @@ -81,6 +124,7 @@ extension DamusPurple.StoreKitManager { } } +// This protocol is used to describe the delegate of the StoreKitManager, which will receive updates. protocol DamusPurpleStoreKitManagerDelegate { func product_was_purchased(product: DamusPurple.StoreKitManager.PurchasedProduct) } diff --git a/damus/Views/Purple/DamusPurpleAccountView.swift b/damus/Views/Purple/DamusPurpleAccountView.swift index d77ce051..8b6753ad 100644 --- a/damus/Views/Purple/DamusPurpleAccountView.swift +++ b/damus/Views/Purple/DamusPurpleAccountView.swift @@ -65,11 +65,6 @@ struct DamusPurpleAccountView: View { .preferredColorScheme(.dark) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .padding() - - Text(NSLocalizedString("Visit the Damus website on a web browser to manage billing", comment: "Instruction on how to manage billing externally")) - .font(.caption) - .foregroundColor(.white.opacity(0.6)) - .multilineTextAlignment(.center) } } diff --git a/damus/Views/Purple/DamusPurpleView.swift b/damus/Views/Purple/DamusPurpleView.swift index 73983c31..11644033 100644 --- a/damus/Views/Purple/DamusPurpleView.swift +++ b/damus/Views/Purple/DamusPurpleView.swift @@ -28,7 +28,8 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { @State var purchased: PurchasedProduct? = nil @State var selection: DamusPurple.StoreKitManager.DamusPurpleType = .yearly @State var show_welcome_sheet: Bool = false - @State var show_manage_subscriptions = false + @State var account_uuid: UUID? = nil + @State var iap_error: String? = nil // TODO: Display this error to the user in some way. @State private var shouldDismissView = false @Environment(\.dismiss) var dismiss @@ -37,7 +38,6 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { self._products = State(wrappedValue: .loading) self.damus_state = damus_state self.keypair = damus_state.keypair - damus_state.purple.storekit_manager.delegate = self } // MARK: - Top level view @@ -69,6 +69,10 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { notify(.display_tabbar(false)) Task { await self.load_account() + // Assign this view as the delegate for the storekit manager to receive purchase updates + damus_state.purple.storekit_manager.delegate = self + // Fetch the account UUID to use for IAP purchases and to check if an IAP purchase is associated with the account + self.account_uuid = try await damus_state.purple.get_maybe_cached_uuid_for_account() } } .onDisappear { @@ -86,7 +90,6 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { }, content: { DamusPurpleNewUserOnboardingView(damus_state: damus_state) }) - .manageSubscriptionsSheet(isPresented: $show_manage_subscriptions) } // MARK: - Complex subviews @@ -100,7 +103,11 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { ProgressView() .progressViewStyle(.circular) case .loaded(let account): - DamusPurpleAccountView(damus_state: damus_state, account: account) + Group { + DamusPurpleAccountView(damus_state: damus_state, account: account) + + ProductStateView(account: account) + } case .no_account: MarketingContent case .error(let message): @@ -119,20 +126,45 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { DamusPurpleView.MarketingContentView(purple: damus_state.purple) VStack(alignment: .center) { - ProductStateView + ProductStateView(account: nil) } .padding([.top], 20) } } - var ProductStateView: some View { + func ProductStateView(account: DamusPurple.Account?) -> some View { Group { if damus_state.purple.enable_purple_iap_support { - DamusPurpleView.IAPProductStateView(products: products, purchased: purchased, subscribe: subscribe) + if account?.active == true && purchased == nil { + // Account active + no IAP purchases = Bought through Lightning. + // Instruct the user to manage billing on the website + ManageOnWebsiteNote + } + else { + // If account is no longer active or was purchased via IAP, then show IAP purchase/manage options + if let account_uuid { + DamusPurpleView.IAPProductStateView(products: products, purchased: purchased, account_uuid: account_uuid, subscribe: subscribe) + } + else { + ProgressView() + .progressViewStyle(.circular) + } + } + + } + else { + ManageOnWebsiteNote } } } + var ManageOnWebsiteNote: some View { + Text(NSLocalizedString("Visit the Damus website on a web browser to manage billing", comment: "Instruction on how to manage billing externally")) + .font(.caption) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + } + // MARK: - State management func load_account() async { @@ -167,31 +199,12 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { } func subscribe(_ product: Product) async throws { - let result = try await self.damus_state.purple.make_iap_purchase(product: product) - switch result { - case .success(.verified(let tx)): - print("success \(tx.debugDescription)") - show_welcome_sheet = true - case .success(.unverified(let tx, let res)): - print("success unverified \(tx.debugDescription) \(res.localizedDescription)") - show_welcome_sheet = true - case .pending: - break - case .userCancelled: - break - @unknown default: - break + do { + try await self.damus_state.purple.make_iap_purchase(product: product) + show_welcome_sheet = true } - - switch result { - case .success: - // TODO (will): why do this here? - //self.damus_state.purple.starred_profiles_cache[keypair.pubkey] = nil - Task { - await self.damus_state.purple.send_receipt() - } - default: - break + catch(let error) { + self.iap_error = error.localizedDescription } } } diff --git a/damus/Views/Purple/Detail/IAPProductStateView.swift b/damus/Views/Purple/Detail/IAPProductStateView.swift index 1a8b5b38..742e95b7 100644 --- a/damus/Views/Purple/Detail/IAPProductStateView.swift +++ b/damus/Views/Purple/Detail/IAPProductStateView.swift @@ -12,12 +12,16 @@ import StoreKit extension DamusPurpleView { typealias PurchasedProduct = DamusPurple.StoreKitManager.PurchasedProduct + static let SHOW_IAP_DEBUG_INFO = false struct IAPProductStateView: View { - let products: ProductState - let purchased: PurchasedProduct? + var products: ProductState + var purchased: PurchasedProduct? + let account_uuid: UUID let subscribe: (Product) async throws -> Void + @State var show_manage_subscriptions = false + var body: some View { switch self.products { case .failed: @@ -35,31 +39,65 @@ extension DamusPurpleView { } func PurchasedView(_ purchased: PurchasedProduct) -> some View { - VStack(spacing: 10) { - Text(NSLocalizedString("Purchased!", comment: "User purchased a subscription")) - .font(.title2) - .foregroundColor(.white) - price_description(product: purchased.product) - .foregroundColor(.white) - .opacity(0.65) - .frame(width: 200) - Text(NSLocalizedString("Purchased on", comment: "Indicating when the user purchased the subscription")) - .font(.title2) - .foregroundColor(.white) - Text(format_date(date: purchased.tx.purchaseDate)) - .foregroundColor(.white) - .opacity(0.65) - if let expiry = purchased.tx.expirationDate { - Text(NSLocalizedString("Renews on", comment: "Indicating when the subscription will renew")) - .font(.title2) - .foregroundColor(.white) - Text(format_date(date: expiry)) - .foregroundColor(.white) - .opacity(0.65) + return Group { + if self.account_uuid == purchased.tx.appAccountToken { + // If the In-app purchase is registered to this account, show options to manage the subscription + PurchasedManageView(purchased) + } + else { + // If the In-app purchase is registered to a different account, we cannot manage the subscription + // This seems to be a limitation of StoreKit where we can only have one renewable subscription for a product at a time. + // Therefore, instruct the user about this limitation, or to contact us if they believe this is a mistake. + PurchasedUnmanageableView(purchased) } } } + func PurchasedUnmanageableView(_ purchased: PurchasedProduct) -> some View { + Text(NSLocalizedString("This device's in-app purchase is registered to a different Nostr account. Unable to manage this Purple account. If you believe this was a mistake, please contact us via support@damus.io.", comment: "Notice label that user cannot manage their In-App purchases")) + .font(.caption) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + func PurchasedManageView(_ purchased: PurchasedProduct) -> some View { + VStack(spacing: 10) { + if SHOW_IAP_DEBUG_INFO == true { + Text(NSLocalizedString("Purchased!", comment: "User purchased a subscription")) + .font(.title2) + .foregroundColor(.white) + price_description(product: purchased.product) + .foregroundColor(.white) + .opacity(0.65) + .frame(width: 200) + Text(NSLocalizedString("Purchased on", comment: "Indicating when the user purchased the subscription")) + .font(.title2) + .foregroundColor(.white) + Text(format_date(date: purchased.tx.purchaseDate)) + .foregroundColor(.white) + .opacity(0.65) + if let expiry = purchased.tx.expirationDate { + Text(NSLocalizedString("Renews on", comment: "Indicating when the subscription will renew")) + .font(.title2) + .foregroundColor(.white) + Text(format_date(date: expiry)) + .foregroundColor(.white) + .opacity(0.65) + } + } + Button(action: { + show_manage_subscriptions = true + }, label: { + Text(NSLocalizedString("Manage", comment: "Manage the damus subscription")) + .padding(.horizontal, 20) + }) + .buttonStyle(GradientButtonStyle()) + } + .manageSubscriptionsSheet(isPresented: $show_manage_subscriptions) + .padding() + } + func ProductsView(_ products: [Product]) -> some View { VStack(spacing: 10) { Text(NSLocalizedString("Save 20% off on an annual subscription", comment: "Savings for purchasing an annual subscription")) @@ -80,7 +118,7 @@ extension DamusPurpleView { .buttonStyle(GradientButtonStyle()) } } - .padding(.horizontal, 20) + .padding() } func price_description(product: Product) -> some View { @@ -124,5 +162,5 @@ extension DamusPurpleView { } #Preview { - DamusPurpleView.IAPProductStateView(products: .loaded([]), purchased: nil, subscribe: {_ in }) + DamusPurpleView.IAPProductStateView(products: .loaded([]), purchased: nil, account_uuid: UUID(), subscribe: {_ in }) }