diff --git a/Sources/Error Handling/ErrorDetails.swift b/Sources/Error Handling/ErrorDetails.swift index 8c0e975cdf..9d42256ac4 100644 --- a/Sources/Error Handling/ErrorDetails.swift +++ b/Sources/Error Handling/ErrorDetails.swift @@ -20,6 +20,7 @@ extension NSError.UserInfoKey { static let attributeErrorsResponse: NSError.UserInfoKey = "attributes_error_response" static let statusCode: NSError.UserInfoKey = "rc_response_status_code" static let obfuscatedEmail: NSError.UserInfoKey = "rc_obfuscated_email" + static let rootError: NSError.UserInfoKey = "rc_root_error" static let readableErrorCode: NSError.UserInfoKey = "readable_error_code" static let backendErrorCode: NSError.UserInfoKey = "rc_backend_error_code" @@ -35,6 +36,7 @@ enum ErrorDetails { static let attributeErrorsResponseKey = NSError.UserInfoKey.attributeErrorsResponse as String static let statusCodeKey = NSError.UserInfoKey.statusCode as String static let obfuscatedEmailKey = NSError.UserInfoKey.obfuscatedEmail as String + static let rootErrorKey = NSError.UserInfoKey.rootError as String static let readableErrorCodeKey = NSError.UserInfoKey.readableErrorCode as String static let extraContextKey = NSError.UserInfoKey.extraContext as String diff --git a/Sources/Error Handling/PurchasesError.swift b/Sources/Error Handling/PurchasesError.swift index 0b3359ef39..2339edaf46 100644 --- a/Sources/Error Handling/PurchasesError.swift +++ b/Sources/Error Handling/PurchasesError.swift @@ -12,6 +12,7 @@ // Created by Nacho Soto on 8/31/22. import Foundation +import StoreKit /// An error returned by a `RevenueCat` public API. public typealias PublicError = NSError @@ -38,8 +39,56 @@ extension PurchasesError { /// let error = ErrorUtils.unknownError().asPublicError /// let errorCode = error as? ErrorCode /// ``` + /// + /// Info about the root error can be accessed in userInfo. + /// Example: + /// ``` + /// let error = ErrorUtils.unknownError().asPublicError + /// let rootErrorInfo = error.userInfo["rc_root_error"] as? [String: Any] + /// let rootErrorCode = rootErrorInfo?["code"] as? Int + /// let rootErrorDomain = rootErrorInfo?["domain"] as? String + /// let rootErrorLocalizedDescription = rootErrorInfo?["localizedDescription"] as? String + /// ``` + /// + /// If the root error comes from StoreKit, some extra info will be added to the root error. + /// Example: + /// ``` + /// let error = ErrorUtils.unknownError().asPublicError + /// let rootErrorInfo = error.userInfo["rc_root_error"] as? [String: Any] + /// let storeKitErrorInfo = rootErrorInfo?["storeKitError"] as? [String: Any] + /// let storeKitErrorDescription = storeKitErrorInfo?["description"] as? String + /// // If it's a SKError: + /// let skErrorCode = storeKitErrorInfo?["skErrorCode"] as? Int + /// // If it's a StoreKitError.networkError: + /// let urlErrorCode = storeKitErrorInfo?["urlErrorCode"] as? Int + /// let urlErrorFailingUrl = storeKitErrorInfo?["urlErrorFailingUrl"] as? String + /// // If it's a StoreKitError.systemError: + /// let systemErrorDescription = storeKitErrorInfo?["systemErrorDescription"] as? Int + /// ``` var asPublicError: PublicError { - return NSError(domain: Self.errorDomain, code: self.errorCode, userInfo: self.userInfo) + let rootError: Error = self.rootError(from: self) + let rootNSError = rootError as NSError + var rootErrorInfo: [String: Any] = [ + "code": rootNSError.code, + "domain": rootNSError.domain, + "localizedDescription": rootNSError.localizedDescription + ] + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { + if let storeKitErrorInfo = self.getStoreKitErrorInfoIfAny(error: rootError) { + rootErrorInfo = rootErrorInfo.merging(["storeKitError": storeKitErrorInfo]) + } + } + let userInfoToUse = self.userInfo.merging([ErrorDetails.rootErrorKey: rootErrorInfo]) + return NSError(domain: Self.errorDomain, code: self.errorCode, userInfo: userInfoToUse) + } + + private func rootError(from error: Error) -> Error { + let nsError = error as NSError + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? Error { + return rootError(from: underlyingError) + } else { + return error + } } } @@ -65,3 +114,43 @@ extension PurchasesError { } } + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +private extension PurchasesError { + + func getStoreKitErrorInfoIfAny(error: Error) -> [String: Any]? { + if let skError = error as? SKError { + return [ + "skErrorCode": skError.code.rawValue, + "description": skError.code.trackingDescription + ] + } else if let storeKitError = error as? StoreKitError { + let resultMap: [String: Any] = ["description": storeKitError.trackingDescription] + switch storeKitError { + case .unknown, + .userCancelled, + .notAvailableInStorefront, + .notEntitled: + return resultMap + case let .networkError(urlError): + return resultMap.merging([ + "urlErrorCode": urlError.errorCode, + "urlErrorFailingUrl": urlError.failureURLString ?? "missing_value" + ]) + case let .systemError(systemError): + return resultMap.merging([ + "systemErrorDescription": systemError.localizedDescription + ]) + + @unknown default: + Logger.warn(Strings.storeKit.unknown_storekit_error(storeKitError)) + return resultMap + } + } else if let storeKitError = error as? StoreKit.Product.PurchaseError { + return ["description": storeKitError.trackingDescription] + } else { + return nil + } + } + +} diff --git a/Sources/Logging/Strings/StoreKitStrings.swift b/Sources/Logging/Strings/StoreKitStrings.swift index a8ef36d457..eb50d81405 100644 --- a/Sources/Logging/Strings/StoreKitStrings.swift +++ b/Sources/Logging/Strings/StoreKitStrings.swift @@ -91,6 +91,8 @@ enum StoreKitStrings { case error_displaying_store_message(Error) + case unknown_storekit_error(Error) + } extension StoreKitStrings: LogMessage { @@ -215,6 +217,9 @@ extension StoreKitStrings: LogMessage { case let .error_displaying_store_message(error): return "Error displaying StoreKit message: '\(error)'" + + case let .unknown_storekit_error(error): + return "Unknown StoreKit error. Error: '\(error.localizedDescription)'" } } diff --git a/Tests/UnitTests/Purchasing/ErrorUtilsTests.swift b/Tests/UnitTests/Purchasing/ErrorUtilsTests.swift index afdc1d6f51..f5d51e5d71 100644 --- a/Tests/UnitTests/Purchasing/ErrorUtilsTests.swift +++ b/Tests/UnitTests/Purchasing/ErrorUtilsTests.swift @@ -83,6 +83,116 @@ class ErrorUtilsTests: TestCase { } } + func testPublicErrorsContainRootError() throws { + let underlyingError = ErrorUtils.offlineConnectionError().asPublicError + + func throwing() throws { + throw ErrorUtils.customerInfoError(error: underlyingError).asPublicError + } + + do { + try throwing() + fail("Expected error") + } catch let error as NSError { + expect(error).to(matchError(ErrorCode.customerInfoError)) + let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any] + expect(rootErrorInfo).notTo(beNil()) + expect(rootErrorInfo!["code"] as? Int) == 35 + expect(rootErrorInfo!["domain"] as? String) == "RevenueCat.ErrorCode" + expect(rootErrorInfo!["localizedDescription"] as? String) + == "Error performing request because the internet connection appears to be offline." + expect(rootErrorInfo?.keys.count) == 3 + } + } + + func testPublicErrorsRootErrorContainsSKErrorInfo() throws { + let underlyingError = SKError(SKError.Code.paymentInvalid, userInfo: [:]) + + func throwing() throws { + throw ErrorUtils.purchaseInvalidError(error: underlyingError).asPublicError + } + + do { + try throwing() + fail("Expected error") + } catch let error as NSError { + let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any] + expect(rootErrorInfo).notTo(beNil()) + expect(rootErrorInfo!["code"] as? Int) == 3 + expect(rootErrorInfo!["domain"] as? String) == "SKErrorDomain" + expect(rootErrorInfo!["localizedDescription"] as? String) + == "The operation couldn’t be completed. (SKErrorDomain error 3.)" + let storeKitError = rootErrorInfo!["storeKitError"] as? [String: Any] + expect(rootErrorInfo?.keys.count) == 4 + expect(storeKitError).notTo(beNil()) + expect(storeKitError!["skErrorCode"] as? Int) == 3 + expect(storeKitError!["description"] as? String) == "payment_invalid" + expect(storeKitError?.keys.count) == 2 + + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + func testPublicErrorsRootErrorContainsStoreKitErrorInfo() throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + + let underlyingError = StoreKitError.systemError(NSError(domain: "StoreKitSystemError", code: 1234)) + + func throwing() throws { + throw ErrorUtils.purchaseInvalidError(error: underlyingError).asPublicError + } + + do { + try throwing() + fail("Expected error") + } catch let error as NSError { + let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any] + expect(rootErrorInfo).notTo(beNil()) + expect(rootErrorInfo!["code"] as? Int) == 1 + expect(rootErrorInfo!["domain"] as? String) == "StoreKit.StoreKitError" + expect(rootErrorInfo!["localizedDescription"] as? String) + == "The operation couldn’t be completed. (StoreKitSystemError error 1234.)" + let storeKitError = rootErrorInfo!["storeKitError"] as? [String: Any] + expect(rootErrorInfo?.keys.count) == 4 + expect(storeKitError).notTo(beNil()) + expect(storeKitError!["description"] as? String) + == "system_error_Error Domain=StoreKitSystemError Code=1234 \"(null)\"" + expect(storeKitError!["systemErrorDescription"] as? String) + == "The operation couldn’t be completed. (StoreKitSystemError error 1234.)" + expect(storeKitError?.keys.count) == 2 + + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + func testPublicErrorsRootErrorContainsStoreKitProductPurchaseErrorInfo() throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + + let underlyingError = StoreKit.Product.PurchaseError.productUnavailable + + func throwing() throws { + throw ErrorUtils.purchaseInvalidError(error: underlyingError).asPublicError + } + + do { + try throwing() + fail("Expected error") + } catch let error as NSError { + let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any] + expect(rootErrorInfo).notTo(beNil()) + expect(rootErrorInfo!["code"] as? Int) == 1 + expect(rootErrorInfo!["domain"] as? String) == "StoreKit.Product.PurchaseError" + expect(rootErrorInfo!["localizedDescription"] as? String) + == "Item Unavailable" + let storeKitError = rootErrorInfo!["storeKitError"] as? [String: Any] + expect(rootErrorInfo?.keys.count) == 4 + expect(storeKitError).notTo(beNil()) + expect(storeKitError!["description"] as? String) + == "product_unavailable" + expect(storeKitError?.keys.count) == 1 + } + } + func testPurchasesErrorWithUntypedErrorCode() throws { let error: ErrorCode = .apiEndpointBlockedError @@ -92,10 +202,10 @@ class ErrorUtilsTests: TestCase { func testPurchasesErrorWithUntypedPublicError() throws { let error: PublicError = ErrorUtils.configurationError().asPublicError let purchasesError = ErrorUtils.purchasesError(withUntypedError: error) - let userInfo = try XCTUnwrap(purchasesError.userInfo as? [String: String]) + let userInfoDescription = purchasesError.userInfo.description expect(error).to(matchError(purchasesError)) - expect(userInfo) == error.userInfo as? [String: String] + expect(userInfoDescription) == error.userInfo.description } func testPurchasesErrorWithUntypedPurchasesError() throws {