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 }