damus

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

DamusPurple.swift (21033B)


      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     @MainActor
    367     /// This function checks the status of a specific checkout id with the server
    368     /// You should use this result immediately, since it will internally be marked as handled
    369     ///
    370     /// - Returns: true if this checkout is all good to go. false if not. nil if checkout was not found.
    371     func check_and_mark_ln_checkout_is_good_to_go(checkout_id: String) async throws -> Bool? {
    372         let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
    373         if checkout_info?.completed == true {
    374             self.checkout_ids_in_progress.remove(checkout_id)    // Remove if from the list of checkouts in progress
    375         }
    376         return checkout_info?.is_all_good()
    377     }
    378     
    379     struct Account {
    380         let pubkey: Pubkey
    381         let created_at: Date
    382         let expiry: Date
    383         let subscriber_number: Int
    384         let active: Bool
    385 
    386         func ordinal() -> String? {
    387             let number = Int(self.subscriber_number)
    388             let formatter = NumberFormatter()
    389             formatter.numberStyle = .ordinal
    390             return formatter.string(from: NSNumber(integerLiteral: number))
    391         }
    392 
    393         static func from(json_data: Data) -> Self? {
    394             guard let payload = try? JSONDecoder().decode(Payload.self, from: json_data) else { return nil }
    395             return Self.from(payload: payload)
    396         }
    397         
    398         static func from(payload: Payload) -> Self? {
    399             guard let pubkey = Pubkey(hex: payload.pubkey) else { return nil }
    400             return Self(
    401                 pubkey: pubkey,
    402                 created_at: Date.init(timeIntervalSince1970: TimeInterval(payload.created_at)),
    403                 expiry: Date.init(timeIntervalSince1970: TimeInterval(payload.expiry)),
    404                 subscriber_number: Int(payload.subscriber_number),
    405                 active: payload.active
    406             )
    407         }
    408         
    409         struct Payload: Codable {
    410             let pubkey: String              // Hex-encoded string
    411             let created_at: UInt64          // Unix timestamp
    412             let expiry: UInt64              // Unix timestamp
    413             let subscriber_number: UInt
    414             let active: Bool
    415         }
    416     }
    417 }
    418 
    419 // MARK: API types
    420 
    421 extension DamusPurple {
    422     fileprivate struct AccountInfo: Codable {
    423         let pubkey: String
    424         let created_at: UInt64
    425         let expiry: UInt64?
    426         let active: Bool
    427     }
    428     
    429     struct LNCheckoutInfo: Codable {
    430         // 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
    431         // Therefore, to avoid compatibility concerns and complexity, we should only use the fields we need
    432         // The ones we do not need yet will be left commented out until we need them.
    433         let id: UUID
    434         /*
    435         let product_template_name: String
    436         let verified_pubkey: String?
    437         */
    438         let invoice: Invoice?
    439         let completed: Bool
    440         
    441         
    442         struct Invoice: Codable {
    443             /*
    444             let bolt11: String
    445             let label: String
    446             let connection_params: ConnectionParams
    447             */
    448             let paid: Bool?
    449             
    450             /*
    451             struct ConnectionParams: Codable {
    452                 let nodeid: String
    453                 let address: String
    454                 let rune: String
    455             }
    456             */
    457         }
    458         
    459         /// Indicates whether this checkout is all good to go.
    460         /// The checkout is good to go if it is marked as complete and the invoice has been successfully paid
    461         /// - Returns: true if this checkout is all good to go. false otherwise
    462         func is_all_good() -> Bool {
    463             return self.completed == true && self.invoice?.paid == true
    464         }
    465     }
    466     
    467     fileprivate struct AccountUUIDInfo: Codable {
    468         let account_uuid: UUID
    469     }
    470 }
    471 
    472 // MARK: Helper structures
    473 
    474 extension DamusPurple {
    475     enum PurpleError: Error {
    476         case translation_error(status_code: Int, response: Data)
    477         case http_response_error(status_code: Int, response: Data)
    478         case error_processing_response
    479         case iap_purchase_error(result: Product.PurchaseResult)
    480         case iap_receipt_verification_error(status: Int, response: Data)
    481         case translation_no_response
    482         case checkout_npub_verification_error
    483     }
    484     
    485     struct TranslationResult: Codable {
    486         let text: String
    487     }
    488     
    489     struct OnboardingStatus {
    490         var account_existed_at_the_start: Bool? = nil
    491         var onboarding_was_shown: Bool = false
    492         
    493         init() {
    494             
    495         }
    496         
    497         init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) {
    498             self.account_existed_at_the_start = account_active_at_the_start
    499             self.onboarding_was_shown = onboarding_was_shown
    500         }
    501         
    502         func user_has_never_seen_the_onboarding_before() -> Bool {
    503             return onboarding_was_shown == false && account_existed_at_the_start == false
    504         }
    505     }
    506 }