From 8b827584a557412a7aae778f93913e130fd07498 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sun, 28 Aug 2022 18:30:32 -0700 Subject: [PATCH] Fixed crash on `async` SK1 cancelled purchase Fixes #1868. See https://github.com/RevenueCat/purchases-ios/issues/1868#issuecomment-1229614774 for a summary of the changes in #1841. ### Before #1841: - SK1 / completion-block: no `CustomerInfo` + cancelled + `ErrorCode.purchaseCancelledError` - SK1 / `async`: thrown `ErrorCode.purchaseCancelledError` - SK2 / completion-block: `CustomerInfo` + cancelled + no error - SK2 / `async`: `CustomerInfo` + cancelled (not error) ### After #1841: - SK1 / completion-block: no `CustomerInfo` + cancelled + `ErrorCode.purchaseCancelledError` - SK1 / `async`: no `CustomerInfo` + ignored error -> crash! :rotating_light: - SK2 / completion-block: `CustomerInfo` + cancelled + `ErrorCode.purchaseCancelledError` - SK2 / `async`: `CustomerInfo` + cancelled (not error) ### After this PR: - SK1 / completion-block: no `CustomerInfo` + cancelled + `ErrorCode.purchaseCancelledError` - **SK1 / `async`: thrown `ErrorCode.purchaseCancelledError`** :warning: - SK2 / completion-block: `CustomerInfo` + cancelled + `ErrorCode.purchaseCancelledError` - SK2 / `async`: `CustomerInfo` + cancelled (not error) The change in #1841 was slightly incorrect. The `ignoreIfPurchaseCancelled` didn't make a lot of sense because `Result(value:error:)` already ignored the error if the value wasn't `nil`. This change still doesn't get us to a fully consistent behavior across SK1 and SK2 but it adds coverage for this crash and temporarily fixes it. --- Sources/Misc/Purchases+async.swift | 18 +++-------- .../Purchases/PurchasesPurchasingTests.swift | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Sources/Misc/Purchases+async.swift b/Sources/Misc/Purchases+async.swift index 1dec713774..548c983503 100644 --- a/Sources/Misc/Purchases+async.swift +++ b/Sources/Misc/Purchases+async.swift @@ -66,7 +66,7 @@ extension Purchases { func purchaseAsync(product: StoreProduct) async throws -> PurchaseResultData { return try await withCheckedThrowingContinuation { continuation in purchase(product: product) { transaction, customerInfo, error, userCancelled in - continuation.resume(with: Result(customerInfo, error?.ignoreIfPurchaseCancelled(userCancelled)) + continuation.resume(with: Result(customerInfo, error) .map { PurchaseResultData(transaction, $0, userCancelled) }) } } @@ -76,7 +76,7 @@ extension Purchases { func purchaseAsync(package: Package) async throws -> PurchaseResultData { return try await withCheckedThrowingContinuation { continuation in purchase(package: package) { transaction, customerInfo, error, userCancelled in - continuation.resume(with: Result(customerInfo, error?.ignoreIfPurchaseCancelled(userCancelled)) + continuation.resume(with: Result(customerInfo, error) .map { PurchaseResultData(transaction, $0, userCancelled) }) } } @@ -87,7 +87,7 @@ extension Purchases { return try await withCheckedThrowingContinuation { continuation in purchase(product: product, promotionalOffer: promotionalOffer) { transaction, customerInfo, error, userCancelled in - continuation.resume(with: Result(customerInfo, error?.ignoreIfPurchaseCancelled(userCancelled)) + continuation.resume(with: Result(customerInfo, error) .map { PurchaseResultData(transaction, $0, userCancelled) }) } } @@ -98,7 +98,7 @@ extension Purchases { return try await withCheckedThrowingContinuation { continuation in purchase(package: package, promotionalOffer: promotionalOffer) { transaction, customerInfo, error, userCancelled in - continuation.resume(with: Result(customerInfo, error?.ignoreIfPurchaseCancelled(userCancelled)) + continuation.resume(with: Result(customerInfo, error) .map { PurchaseResultData(transaction, $0, userCancelled) }) } } @@ -208,13 +208,3 @@ extension Purchases { #endif } - -private extension Error { - - /// This allows `async` APIs to return `userCancelled` in ``PurchaseResultData`` instead of throwing errors. - /// - Returns: `nil` if `cancelled` is `true` - func ignoreIfPurchaseCancelled(_ cancelled: Bool) -> Self? { - return cancelled ? nil : self - } - -} diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift index 8912a1b5fa..72a843d56a 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift @@ -327,6 +327,37 @@ class PurchasesPurchasingTests: BasePurchasesTests { expect(receivedUnderlyingError?.code) == SKError.Code.paymentCancelled.rawValue } + @MainActor + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + func testUserCancelledTrueIfSK1AsyncPurchaseCancelled() throws { + try AvailabilityChecks.iOS13APIAvailableOrSkipTest() + + let product = StoreProduct(sk1Product: MockSK1Product(mockProductIdentifier: "com.product.id1")) + + var receivedError: NSError? + + _ = Task { + do { + _ = try await self.purchases.purchase( + product: product + ) + } catch { + receivedError = error as NSError + } + } + + expect(self.storeKitWrapper.payment).toEventuallyNot(beNil()) + + let transaction = MockTransaction() + transaction.mockPayment = try XCTUnwrap(self.storeKitWrapper.payment) + transaction.mockState = .failed + transaction.mockError = NSError(domain: SKErrorDomain, code: SKError.Code.paymentCancelled.rawValue) + self.storeKitWrapper.delegate?.storeKitWrapper(self.storeKitWrapper, updatedTransaction: transaction) + + expect(receivedError).toEventuallyNot(beNil()) + expect(receivedError).to(matchError(ErrorCode.purchaseCancelledError)) + } + func testDoNotSendEmptyReceiptWhenMakingPurchase() { self.receiptFetcher.shouldReturnReceipt = false