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 }