damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

DamusPurple.swift (24458B)


      1 //
      2 //  DamusPurple.swift
      3 //  damus
      4 //
      5 //  Created by Daniel D’Aquino on 2023-12-08.
      6 //
      7 
      8 import Foundation
      9 import StoreKit
     10 
     11 class DamusPurple: StoreObserverDelegate {
     12     let settings: UserSettingsStore
     13     let keypair: Keypair
     14     var storekit_manager: StoreKitManager
     15     var checkout_ids_in_progress: Set<String> = []
     16     var onboarding_status: OnboardingStatus
     17 
     18     @MainActor
     19     var account_cache: [Pubkey: Account]
     20     @MainActor
     21     var account_uuid_cache: [Pubkey: UUID]
     22 
     23     init(settings: UserSettingsStore, keypair: Keypair) {
     24         self.settings = settings
     25         self.keypair = keypair
     26         self.account_cache = [:]
     27         self.account_uuid_cache = [:]
     28         self.storekit_manager = StoreKitManager.standard    // Use singleton to avoid losing local purchase data
     29         self.onboarding_status = OnboardingStatus()
     30         Task {
     31             let account: Account? = try await self.fetch_account(pubkey: self.keypair.pubkey)
     32             if account == nil {
     33                 self.onboarding_status.account_existed_at_the_start = false
     34             }
     35             else {
     36                 self.onboarding_status.account_existed_at_the_start = true
     37             }
     38         }
     39     }
     40     
     41     // MARK: Functions
     42     func is_profile_subscribed_to_purple(pubkey: Pubkey) async -> Bool? {
     43         return try? await self.get_maybe_cached_account(pubkey: pubkey)?.active
     44     }
     45     
     46     var environment: DamusPurpleEnvironment {
     47         return self.settings.purple_enviroment
     48     }
     49     
     50     var enable_purple: Bool {
     51         return true
     52         // TODO: On release, we could just replace this with `true` (or some other feature flag)
     53         //return self.settings.enable_experimental_purple_api
     54     }
     55     
     56     // Whether to enable Apple In-app purchase support
     57     var enable_purple_iap_support: Bool {
     58         // TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag)
     59         // return self.settings.enable_experimental_purple_iap_support
     60         return true
     61     }
     62 
     63     func account_exists(pubkey: Pubkey) async -> Bool? {
     64         guard let account_data = try? await self.get_account_data(pubkey: pubkey) else { return nil }
     65         
     66         if let account_info = try? JSONDecoder().decode(AccountInfo.self, from: account_data) {
     67             return account_info.pubkey == pubkey.hex()
     68         }
     69         
     70         return false
     71     }
     72 
     73     @MainActor
     74     func get_maybe_cached_account(pubkey: Pubkey) async throws -> Account? {
     75         if let account = self.account_cache[pubkey] {
     76             return account
     77         }
     78         return try await fetch_account(pubkey: pubkey)
     79     }
     80 
     81     @MainActor
     82     func fetch_account(pubkey: Pubkey) async throws -> Account? {
     83         guard let data = try await self.get_account_data(pubkey: pubkey) ,
     84               let account = Account.from(json_data: data) else {
     85             return nil
     86         }
     87         self.account_cache[pubkey] = account
     88         return account
     89     }
     90     
     91     func get_account_data(pubkey: Pubkey) async throws -> Data? {
     92         let url = environment.api_base_url().appendingPathComponent("accounts/\(pubkey.hex())")
     93         
     94         let (data, response) = try await make_nip98_authenticated_request(
     95             method: .get,
     96             url: url,
     97             payload: nil,
     98             payload_type: nil,
     99             auth_keypair: self.keypair
    100         )
    101         
    102         if let httpResponse = response as? HTTPURLResponse {
    103             switch httpResponse.statusCode {
    104                 case 200:
    105                     return data
    106                 case 404:
    107                     return nil
    108                 default:
    109                     throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
    110             }
    111         }
    112         throw PurpleError.error_processing_response
    113     }
    114     
    115     func make_iap_purchase(product: Product) async throws {
    116         let account_uuid = try await self.get_maybe_cached_uuid_for_account()
    117         let result = try await self.storekit_manager.purchase(product: product, id: account_uuid)
    118         switch result {
    119             case .success(.verified(let tx)):
    120                 // Record the purchase with the storekit manager, to make sure we have the update on the UIs as soon as possible.
    121                 // During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
    122                 self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
    123                 await tx.finish()
    124                 // Send the transaction id to the server
    125                 try await self.send_transaction_id(transaction_id: tx.originalID)
    126 
    127             default:
    128                 // Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
    129                 throw PurpleError.iap_purchase_error(result: result)
    130         }
    131     }
    132     
    133     @MainActor
    134     func get_maybe_cached_uuid_for_account() async throws -> UUID {
    135         if let account_uuid = self.account_uuid_cache[self.keypair.pubkey] {
    136             return account_uuid
    137         }
    138         return try await fetch_uuid_for_account()
    139     }
    140     
    141     @MainActor
    142     func fetch_uuid_for_account() async throws -> UUID {
    143         let url = self.environment.api_base_url().appending(path: "/accounts/\(self.keypair.pubkey)/account-uuid")
    144         let (data, response) = try await make_nip98_authenticated_request(
    145             method: .get,
    146             url: url,
    147             payload: nil,
    148             payload_type: nil,
    149             auth_keypair: self.keypair
    150         )
    151         
    152         if let httpResponse = response as? HTTPURLResponse {
    153             switch httpResponse.statusCode {
    154                 case 200:
    155                     Log.info("Got user UUID from Damus Purple server", for: .damus_purple)
    156                 default:
    157                     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")
    158                     throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
    159             }
    160         }
    161         
    162         let account_uuid_info = try JSONDecoder().decode(AccountUUIDInfo.self, from: data)
    163         self.account_uuid_cache[self.keypair.pubkey] = account_uuid_info.account_uuid
    164         return account_uuid_info.account_uuid
    165     }
    166     
    167     func send_receipt() async throws {
    168         // Get the receipt if it's available.
    169         if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
    170             FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
    171 
    172             let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
    173             let receipt_base64_string = receiptData.base64EncodedString()
    174             let account_uuid = try await self.get_maybe_cached_uuid_for_account()
    175             let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString]
    176             let json_data = try JSONSerialization.data(withJSONObject: json_text)
    177 
    178             let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt")
    179 
    180             Log.info("Sending in-app purchase receipt to Damus Purple server: %s", for: .damus_purple, receipt_base64_string)
    181 
    182             let (data, response) = try await make_nip98_authenticated_request(
    183                 method: .post,
    184                 url: url,
    185                 payload: json_data,
    186                 payload_type: .json,
    187                 auth_keypair: self.keypair
    188             )
    189             
    190             if let httpResponse = response as? HTTPURLResponse {
    191                 switch httpResponse.statusCode {
    192                     case 200:
    193                         Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
    194                     default:
    195                         Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
    196                         throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
    197                 }
    198             }
    199         }
    200     }
    201 
    202     func send_transaction_id(transaction_id: UInt64) async throws {
    203         let account_uuid = try await self.get_maybe_cached_uuid_for_account()
    204         let json_text: [String: Any] = ["transaction_id": transaction_id, "account_uuid": account_uuid.uuidString]
    205         let json_data = try JSONSerialization.data(withJSONObject: json_text)
    206 
    207         let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/transaction-id")
    208 
    209         let (data, response) = try await make_nip98_authenticated_request(
    210             method: .post,
    211             url: url,
    212             payload: json_data,
    213             payload_type: .json,
    214             auth_keypair: self.keypair
    215         )
    216 
    217         if let httpResponse = response as? HTTPURLResponse {
    218             switch httpResponse.statusCode {
    219                 case 200:
    220                     Log.info("Sent transaction ID to Damus Purple server and activated successfully", for: .damus_purple)
    221                 default:
    222                     Log.error("Error in sending or verifying transaction ID with Damus Purple server. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
    223                     throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
    224             }
    225         }
    226     }
    227 
    228     func translate(text: String, source source_language: String, target target_language: String) async throws -> String {
    229         var url = environment.api_base_url()
    230         url.append(path: "/translate")
    231         url.append(queryItems: [
    232             .init(name: "source", value: source_language),
    233             .init(name: "target", value: target_language),
    234             .init(name: "q", value: text)
    235         ])
    236         let (data, response) = try await make_nip98_authenticated_request(
    237             method: .get,
    238             url: url,
    239             payload: nil,
    240             payload_type: nil,
    241             auth_keypair: self.keypair
    242         )
    243         
    244         if let httpResponse = response as? HTTPURLResponse {
    245             switch httpResponse.statusCode {
    246                 case 200:
    247                     return try JSONDecoder().decode(TranslationResult.self, from: data).text
    248                 default:
    249                     Log.error("Translation error with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
    250                     throw PurpleError.translation_error(status_code: httpResponse.statusCode, response: data)
    251             }
    252         }
    253         else {
    254             throw PurpleError.translation_no_response
    255         }
    256     }
    257     
    258     func verify_npub_for_checkout(checkout_id: String) async throws {
    259         var url = environment.api_base_url()
    260         url.append(path: "/ln-checkout/\(checkout_id)/verify")
    261         
    262         let (data, response) = try await make_nip98_authenticated_request(
    263             method: .put,
    264             url: url,
    265             payload: nil,
    266             payload_type: nil,
    267             auth_keypair: self.keypair
    268         )
    269         
    270         if let httpResponse = response as? HTTPURLResponse {
    271             switch httpResponse.statusCode {
    272                 case 200:
    273                     Log.info("Verified npub for checkout id `%s` with Damus Purple server", for: .damus_purple, checkout_id)
    274                 default:
    275                     Log.error("Error in verifying npub with Damus Purple. HTTP status code: %d; Response: %s; Checkout id: ", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown", checkout_id)
    276                     throw PurpleError.checkout_npub_verification_error
    277             }
    278         }
    279         
    280     }
    281     
    282     @MainActor
    283     func fetch_ln_checkout_object(checkout_id: String) async throws -> LNCheckoutInfo? {
    284         let url = environment.api_base_url().appendingPathComponent("ln-checkout/\(checkout_id)")
    285         
    286         let (data, response) = try await make_nip98_authenticated_request(
    287             method: .get,
    288             url: url,
    289             payload: nil,
    290             payload_type: nil,
    291             auth_keypair: self.keypair
    292         )
    293         
    294         if let httpResponse = response as? HTTPURLResponse {
    295             switch httpResponse.statusCode {
    296                 case 200:
    297                     return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
    298                 case 404:
    299                     return nil
    300                 default:
    301                     throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
    302             }
    303         }
    304         throw PurpleError.error_processing_response
    305     }
    306     
    307     @MainActor
    308     func new_ln_checkout(product_template_name: String) async throws -> LNCheckoutInfo? {
    309         let url = environment.api_base_url().appendingPathComponent("ln-checkout")
    310         
    311         let json_text: [String: String] = ["product_template_name": product_template_name]
    312         let json_data = try JSONSerialization.data(withJSONObject: json_text)
    313         
    314         let (data, response) = try await make_nip98_authenticated_request(
    315             method: .post,
    316             url: url,
    317             payload: json_data,
    318             payload_type: .json,
    319             auth_keypair: self.keypair
    320         )
    321         
    322         if let httpResponse = response as? HTTPURLResponse {
    323             switch httpResponse.statusCode {
    324                 case 200:
    325                     return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
    326                 case 404:
    327                     return nil
    328                 default:
    329                     throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
    330             }
    331         }
    332         throw PurpleError.error_processing_response
    333     }
    334     
    335     @MainActor
    336     func generate_verified_ln_checkout_link(product_template_name: String) async throws -> URL {
    337         let checkout = try await self.new_ln_checkout(product_template_name: product_template_name)
    338         guard let checkout_id = checkout?.id.uuidString.lowercased() else { throw PurpleError.error_processing_response }
    339         try await self.verify_npub_for_checkout(checkout_id: checkout_id)
    340         return self.environment.purple_landing_page_url()
    341             .appendingPathComponent("checkout")
    342             .appending(queryItems: [URLQueryItem(name: "id", value: checkout_id)])
    343     }
    344     
    345     @MainActor
    346     /// This function checks the status of all checkout objects in progress with the server, and it does two things:
    347     /// - It returns the ones that were freshly completed
    348     /// - It internally marks them as "completed"
    349     /// Important note: If you call this function, you must use the result, as those checkouts will not be returned the next time you call this function
    350     ///
    351     /// - Returns: An array of checkout objects that have been successfully completed.
    352     func check_status_of_checkouts_in_progress() async throws -> [String] {
    353         var freshly_completed_checkouts: [String] = []
    354         for checkout_id in self.checkout_ids_in_progress {
    355             let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
    356             if checkout_info?.is_all_good() == true {
    357                 freshly_completed_checkouts.append(checkout_id)
    358             }
    359             if checkout_info?.completed == true {
    360                 self.checkout_ids_in_progress.remove(checkout_id)
    361             }
    362         }
    363         return freshly_completed_checkouts
    364     }
    365     
    366     /// Handles a Purple URL
    367     /// - Parameter purple_url: The Purple URL being opened
    368     /// - Returns: A view to be shown in the UI
    369     @MainActor
    370     func handle(purple_url: DamusPurpleURL) async -> ContentView.ViewOpenAction {
    371         if case let .welcome(checkout_id) = purple_url.variant {
    372             // If this is a welcome link, do the following before showing the onboarding screen:
    373             // 1. Check if this is legitimate and good to go.
    374             // 2. Mark as complete if this is good to go.
    375             let is_good_to_go = try? await check_and_mark_ln_checkout_is_good_to_go(checkout_id: checkout_id)
    376             switch is_good_to_go {
    377             case .some(let is_good_to_go):
    378                 if is_good_to_go {
    379                     return .sheet(.purple(purple_url))  // ALL GOOD, SHOW WELCOME SHEET
    380                 }
    381                 else {
    382                     return .sheet(.error(.init(
    383                         user_visible_description: NSLocalizedString("You clicked on a Purple welcome link, but it does not look like the checkout is completed yet. This is likely a bug.", comment: "Error label upon continuing in the app from a Damus Purple purchase"),
    384                         tip: NSLocalizedString("Please double-check the checkout web page, or go to the Side Menu → \"Purple\" to check your account status. If you have already paid, but still don't see your account active, please save the URL of the checkout page where you came from, contact our support, and give us the URL to help you with this issue.", comment: "User-facing tips on what to do if a Purple welcome link doesn't work"),
    385                         technical_info: "Handling Purple URL \"\(purple_url)\" failed, the `is_good_to_go` result was `\(is_good_to_go)`"
    386                     )))
    387                 }
    388             case .none:
    389                 return .sheet(.error(.init(
    390                     user_visible_description: NSLocalizedString("You clicked on a Purple welcome link, but we could not find your checkout. This is likely a bug.", comment: "Error label upon continuing in the app from a Damus Purple purchase"),
    391                     tip: NSLocalizedString("Please double-check the checkout web page, or go to the Side Menu → \"Purple\" to check your account status. If you have already paid, but still don't see your account active, please save the URL of the checkout page where you came from, contact our support, and give us the URL to help you with this issue", comment: "User-facing tips on what to do if a Purple welcome link doesn't work"),
    392                     technical_info: "Handling Purple URL \"\(purple_url)\" failed, the `is_good_to_go` result was `\(String(describing: is_good_to_go))`"
    393                 )))
    394             }
    395         }
    396         else {
    397             // Show the purple url contents
    398             return .sheet(.purple(purple_url))
    399         }
    400     }
    401     
    402     @MainActor
    403     /// This function checks the status of a specific checkout id with the server
    404     /// You should use this result immediately, since it will internally be marked as handled
    405     ///
    406     /// - Returns: true if this checkout is all good to go. false if not. nil if checkout was not found.
    407     func check_and_mark_ln_checkout_is_good_to_go(checkout_id: String) async throws -> Bool? {
    408         let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
    409         if checkout_info?.completed == true {
    410             self.checkout_ids_in_progress.remove(checkout_id)    // Remove if from the list of checkouts in progress
    411         }
    412         return checkout_info?.is_all_good()
    413     }
    414     
    415     struct Account {
    416         let pubkey: Pubkey
    417         let created_at: Date
    418         let expiry: Date
    419         let subscriber_number: Int
    420         let active: Bool
    421         let attributes: PurpleAccountAttributes
    422         
    423         struct PurpleAccountAttributes: OptionSet {
    424             let rawValue: Int
    425             
    426             static let memberForMoreThanOneYear = PurpleAccountAttributes(rawValue: 1 << 0)
    427         }
    428 
    429         func ordinal() -> String? {
    430             let number = Int(self.subscriber_number)
    431             let formatter = NumberFormatter()
    432             formatter.numberStyle = .ordinal
    433             return formatter.string(from: NSNumber(integerLiteral: number))
    434         }
    435 
    436         static func from(json_data: Data) -> Self? {
    437             guard let payload = try? JSONDecoder().decode(Payload.self, from: json_data) else { return nil }
    438             return Self.from(payload: payload)
    439         }
    440         
    441         static func from(payload: Payload) -> Self? {
    442             guard let pubkey = Pubkey(hex: payload.pubkey) else { return nil }
    443             return Self(
    444                 pubkey: pubkey,
    445                 created_at: Date.init(timeIntervalSince1970: TimeInterval(payload.created_at)),
    446                 expiry: Date.init(timeIntervalSince1970: TimeInterval(payload.expiry)),
    447                 subscriber_number: Int(payload.subscriber_number),
    448                 active: payload.active,
    449                 attributes: (payload.attributes?.member_for_more_than_one_year ?? false) ? [.memberForMoreThanOneYear] : []
    450             )
    451         }
    452         
    453         struct Payload: Codable {
    454             let pubkey: String              // Hex-encoded string
    455             let created_at: UInt64          // Unix timestamp
    456             let expiry: UInt64              // Unix timestamp
    457             let subscriber_number: UInt
    458             let active: Bool
    459             let attributes: Attributes?
    460             
    461             struct Attributes: Codable {
    462                 let member_for_more_than_one_year: Bool
    463             }
    464         }
    465     }
    466 }
    467 
    468 // MARK: API types
    469 
    470 extension DamusPurple {
    471     fileprivate struct AccountInfo: Codable {
    472         let pubkey: String
    473         let created_at: UInt64
    474         let expiry: UInt64?
    475         let active: Bool
    476     }
    477     
    478     struct LNCheckoutInfo: Codable {
    479         // Note: Swift will decode a JSON full of extra fields into a Struct with only a subset of them, but not the other way around
    480         // Therefore, to avoid compatibility concerns and complexity, we should only use the fields we need
    481         // The ones we do not need yet will be left commented out until we need them.
    482         let id: UUID
    483         /*
    484         let product_template_name: String
    485         let verified_pubkey: String?
    486         */
    487         let invoice: Invoice?
    488         let completed: Bool
    489         
    490         
    491         struct Invoice: Codable {
    492             /*
    493             let bolt11: String
    494             let label: String
    495             let connection_params: ConnectionParams
    496             */
    497             let paid: Bool?
    498             
    499             /*
    500             struct ConnectionParams: Codable {
    501                 let nodeid: String
    502                 let address: String
    503                 let rune: String
    504             }
    505             */
    506         }
    507         
    508         /// Indicates whether this checkout is all good to go.
    509         /// The checkout is good to go if it is marked as complete and the invoice has been successfully paid
    510         /// - Returns: true if this checkout is all good to go. false otherwise
    511         func is_all_good() -> Bool {
    512             return self.completed == true && self.invoice?.paid == true
    513         }
    514     }
    515     
    516     fileprivate struct AccountUUIDInfo: Codable {
    517         let account_uuid: UUID
    518     }
    519 }
    520 
    521 // MARK: Helper structures
    522 
    523 extension DamusPurple {
    524     enum PurpleError: Error {
    525         case translation_error(status_code: Int, response: Data)
    526         case http_response_error(status_code: Int, response: Data)
    527         case error_processing_response
    528         case iap_purchase_error(result: Product.PurchaseResult)
    529         case iap_receipt_verification_error(status: Int, response: Data)
    530         case translation_no_response
    531         case checkout_npub_verification_error
    532     }
    533     
    534     struct TranslationResult: Codable {
    535         let text: String
    536     }
    537     
    538     struct OnboardingStatus {
    539         var account_existed_at_the_start: Bool? = nil
    540         var onboarding_was_shown: Bool = false
    541         
    542         init() {
    543             
    544         }
    545         
    546         init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) {
    547             self.account_existed_at_the_start = account_active_at_the_start
    548             self.onboarding_was_shown = onboarding_was_shown
    549         }
    550         
    551         func user_has_never_seen_the_onboarding_before() -> Bool {
    552             return onboarding_was_shown == false && account_existed_at_the_start == false
    553         }
    554     }
    555 }