From 160ee40088b41f10df06a883004c7f65b7eff892 Mon Sep 17 00:00:00 2001 From: Mark Villacampa Date: Thu, 18 Jan 2024 12:02:58 +0100 Subject: [PATCH] [SK2] Enable Observer Mode for StoreKit 2 (#3580) --- ...BackendIntegrationTests-Offline.xctestplan | 1 + .../BackendIntegrationTests-Other.xctestplan | 1 + .../BackendIntegrationTests-SK1.xctestplan | 1 + .../Logging/Strings/ConfigureStrings.swift | 22 ++++++-- Sources/Logging/Strings/PurchaseStrings.swift | 3 ++ Sources/Purchasing/Purchases/Purchases.swift | 24 +++++++++ .../Purchases/PurchasesOrchestrator.swift | 2 +- .../Purchasing/Purchases/PurchasesType.swift | 51 +++++++++++++++++++ .../StoreKit2TransactionListener.swift | 14 ++--- .../SwiftAPITester/PurchasesAPI.swift | 5 ++ .../StoreKitIntegrationTests.swift | 13 +++++ ...StoreKitObserverModeIntegrationTests.swift | 38 +++++++++----- Tests/UnitTests/Mocks/MockPurchases.swift | 8 +++ .../MockStoreKit2TransactionListener.swift | 3 +- 14 files changed, 159 insertions(+), 27 deletions(-) diff --git a/BackendIntegrationTests/BackendIntegrationTests-Offline.xctestplan b/BackendIntegrationTests/BackendIntegrationTests-Offline.xctestplan index 7b2a0a7606..e8b081351e 100644 --- a/BackendIntegrationTests/BackendIntegrationTests-Offline.xctestplan +++ b/BackendIntegrationTests/BackendIntegrationTests-Offline.xctestplan @@ -60,6 +60,7 @@ "StoreKit2JWSIntegrationTests", "StoreKit2JWSObserverModeIntegrationTests", "StoreKit2JWSObserverModeWithExistingPurchasesTests", + "StoreKit2NotEnabledObserverModeIntegrationTests", "StoreKit2ObserverModeIntegrationTests", "StoreKit2ObserverModeWithExistingPurchasesTests", "SubscriberAttributesManagerIntegrationTests", diff --git a/BackendIntegrationTests/BackendIntegrationTests-Other.xctestplan b/BackendIntegrationTests/BackendIntegrationTests-Other.xctestplan index c3eaa25ef7..b923468622 100644 --- a/BackendIntegrationTests/BackendIntegrationTests-Other.xctestplan +++ b/BackendIntegrationTests/BackendIntegrationTests-Other.xctestplan @@ -54,6 +54,7 @@ "StoreKit2JWSIntegrationTests", "StoreKit2JWSObserverModeIntegrationTests", "StoreKit2JWSObserverModeWithExistingPurchasesTests", + "StoreKit2NotEnabledObserverModeIntegrationTests", "StoreKit2ObserverModeIntegrationTests", "StoreKit2ObserverModeWithExistingPurchasesTests", "TestCase" diff --git a/BackendIntegrationTests/BackendIntegrationTests-SK1.xctestplan b/BackendIntegrationTests/BackendIntegrationTests-SK1.xctestplan index d00e33d148..965e8977b7 100644 --- a/BackendIntegrationTests/BackendIntegrationTests-SK1.xctestplan +++ b/BackendIntegrationTests/BackendIntegrationTests-SK1.xctestplan @@ -61,6 +61,7 @@ "StoreKit2JWSIntegrationTests", "StoreKit2JWSObserverModeIntegrationTests", "StoreKit2JWSObserverModeWithExistingPurchasesTests", + "StoreKit2NotEnabledObserverModeIntegrationTests", "StoreKit2ObserverModeIntegrationTests", "StoreKit2ObserverModeWithExistingPurchasesTests", "SubscriberAttributesManagerIntegrationTests", diff --git a/Sources/Logging/Strings/ConfigureStrings.swift b/Sources/Logging/Strings/ConfigureStrings.swift index 990ab46a31..2d50e81c3e 100644 --- a/Sources/Logging/Strings/ConfigureStrings.swift +++ b/Sources/Logging/Strings/ConfigureStrings.swift @@ -31,8 +31,6 @@ enum ConfigureStrings { case observer_mode_enabled - case observer_mode_with_storekit2 - case response_verification_mode(Signing.ResponseVerificationMode) case storekit_version(StoreKitVersion) @@ -75,6 +73,12 @@ enum ConfigureStrings { case sk2_required_for_swiftui_paywalls + case handle_transaction_observer_mode_required + + case sk2_required + + case observer_mode_with_storekit2 + } extension ConfigureStrings: LogMessage { @@ -98,8 +102,6 @@ extension ConfigureStrings: LogMessage { return "Debug logging enabled" case .observer_mode_enabled: return "Purchases is configured in observer mode" - case .observer_mode_with_storekit2: - return "Observer mode is not currently compatible with StoreKit 2" case let .response_verification_mode(mode): switch mode { case .disabled: @@ -187,6 +189,18 @@ extension ConfigureStrings: LogMessage { return "Purchases is not configured with StoreKit 2 enabled. This is required in order to detect " + "transactions coming from SwiftUI paywalls. You must use `.with(storeKitVersion: .storeKit2)` " + "when configuring the SDK." + + case .handle_transaction_observer_mode_required: + return "Attempted to manually handle transactions with observer mode not enabled. " + + "You must use `.with(observerMode: true)` when configuring the SDK." + + case .sk2_required: + return "StoreKit 2 must be enabled. You must use `.with(storeKitVersion: .storeKit2)` " + + "when configuring the SDK." + + case .observer_mode_with_storekit2: + return "StoreKit 2 Observer Mode is enabled. You must manually handle newly purchased transactions " + + "calling `Purchases.shared.handleObserverModeTransaction()`." } } diff --git a/Sources/Logging/Strings/PurchaseStrings.swift b/Sources/Logging/Strings/PurchaseStrings.swift index 2ea45915d8..74eb86f8b5 100644 --- a/Sources/Logging/Strings/PurchaseStrings.swift +++ b/Sources/Logging/Strings/PurchaseStrings.swift @@ -87,6 +87,7 @@ enum PurchaseStrings { case caching_presented_offering_identifier(offeringID: String, productID: String) case payment_queue_wrapper_delegate_call_sk1_enabled case restorepurchases_called_with_allow_sharing_appstore_account_false + case sk2_observer_mode_error_processing_transaction(Error) } @@ -339,6 +340,8 @@ extension PurchaseStrings: LogMessage { case .restorepurchases_called_with_allow_sharing_appstore_account_false: return "allowSharingAppStoreAccount is set to false and restorePurchases has been called. " + "Are you sure you want to do this?" + case let .sk2_observer_mode_error_processing_transaction(error): + return "Obserber mode could not process transaction: \(error)" } } diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 8907f0ab1c..cc1265adf4 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -1171,6 +1171,30 @@ public extension Purchases { #endif + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func handleObserverModeTransaction( + _ purchaseResult: StoreKit.Product.PurchaseResult + ) async throws -> StoreTransaction? { + guard self.systemInfo.observerMode else { + throw NewErrorUtils.configurationError( + message: Strings.configure.handle_transaction_observer_mode_required.description + ).asPublicError + } + guard self.systemInfo.storeKitVersion == .storeKit2 else { + throw NewErrorUtils.configurationError( + message: Strings.configure.sk2_required.description + ).asPublicError + } + do { + let (_, transaction) = try await self.purchasesOrchestrator.storeKit2TransactionListener.handle( + purchaseResult: purchaseResult, fromTransactionUpdate: true + ) + return transaction + } catch { + throw NewErrorUtils.purchasesError(withUntypedError: error).asPublicError + } + } + } // swiftlint:enable missing_docs diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 40720d5f97..34a45aa8fe 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -506,7 +506,7 @@ final class PurchasesOrchestrator { // `userCancelled` above comes from `StoreKitError.userCancelled`. // This detects if `Product.PurchaseResult.userCancelled` is true. let (userCancelled, transaction) = try await self.storeKit2TransactionListener - .handle(purchaseResult: result) + .handle(purchaseResult: result, fromTransactionUpdate: false) if userCancelled, self.systemInfo.dangerousSettings.customEntitlementComputation { throw ErrorUtils.purchaseCancelledError() diff --git a/Sources/Purchasing/Purchases/PurchasesType.swift b/Sources/Purchasing/Purchases/PurchasesType.swift index d5bd82630b..6914e94d00 100644 --- a/Sources/Purchasing/Purchases/PurchasesType.swift +++ b/Sources/Purchasing/Purchases/PurchasesType.swift @@ -12,6 +12,7 @@ // Created by Nacho Soto on 9/20/22. import Foundation +import StoreKit // swiftlint:disable file_length @@ -1044,6 +1045,56 @@ public protocol PurchasesSwiftType: AnyObject { #endif + /** + * Use this method only if you already have your own IAP implementation using StoreKit 2 and want to use + * RevenueCat's backend. If you are using StoreKit 1 for your implementation, you do not need this method. + * + * You only need to use this method with *new* purchases. Subscription updates are observed automatically. + * + * #### Example: + * + * ```swift + * // Fetch and purchase the product + * let product = try await StoreKit.Product.products(for: ["my_product_id"]).first + * guard let product = product else { return } + * let result = try await product.purchase() + * // Let RevenueCat handle the transaction result + * _ = try await Purchases.shared.handleObserverModeTransaction(result) + * // Handle the result and finish the transaction + * switch result { + * case .success(let verification): + * switch verification { + * case .unverified(_, _): + * break + * case .verified(let transaction): + * // If the purchase was successful and verified, finish the transaction + * await transaction.finish() + * } + * case .userCancelled: + * break + * case .pending: + * break + * @unknown default: + * break + * } + * ``` + * + * - Warning: You need to finish the transaction yourself after calling this method. + * + * - Parameter purchaseResult: The `StoreKit.Product.PurchaseResult` of the product that was just purchased. + * + * - Throws: An error of type ``ErrorCode`` is thrown if a failure occurs while handling the purchase. + * + * - Returns: A ``StoreTransaction`` if there was a transacton found and handled for the provided product ID. + * + * - Important: This should only be used if you have enabled observer mode during SDK configuration using + * ``Configuration/Builder/with(observerMode:)`` + */ + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func handleObserverModeTransaction( + _ purchaseResult: StoreKit.Product.PurchaseResult + ) async throws -> StoreTransaction? + } // MARK: - diff --git a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift index ee5ac08531..f5c08cf5e6 100644 --- a/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift +++ b/Sources/Purchasing/StoreKit2/StoreKit2TransactionListener.swift @@ -37,10 +37,11 @@ protocol StoreKit2TransactionListenerType: Sendable { func set(delegate: StoreKit2TransactionListenerDelegate) async - /// - Returns: `nil` `CustomerInfo` if purchases were not synced - /// - Throws: Error if purchase was not completed successfully + /// - Throws: ``PurchasesError`` if purchase was not completed successfully + /// - Parameter fromTransactionUpdate: `true` only for transactions detected outside of a manual purchase flow. func handle( - purchaseResult: StoreKit.Product.PurchaseResult + purchaseResult: StoreKit.Product.PurchaseResult, + fromTransactionUpdate: Bool ) async throws -> StoreKit2TransactionListener.ResultData } @@ -113,15 +114,14 @@ actor StoreKit2TransactionListener: StoreKit2TransactionListenerType { self.taskHandle = nil } - /// - Returns: `nil` `CustomerInfo` if purchases were not synced - /// - Throws: Error if purchase was not completed successfully func handle( - purchaseResult: StoreKit.Product.PurchaseResult + purchaseResult: StoreKit.Product.PurchaseResult, + fromTransactionUpdate: Bool = false ) async throws -> ResultData { switch purchaseResult { case let .success(verificationResult): let transaction = try await self.handle(transactionResult: verificationResult, - fromTransactionUpdate: false) + fromTransactionUpdate: fromTransactionUpdate) return (false, transaction) case .pending: throw ErrorUtils.paymentDeferredError() diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift index 24cc19ea82..ca89165e46 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift @@ -265,6 +265,11 @@ private func checkAsyncMethods(purchases: Purchases) async { let _: CustomerInfo = try await purchases.restorePurchases() let _: CustomerInfo = try await purchases.syncPurchases() + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + let result = try await StoreKit.Product.products(for: [""]).first!.purchase() + let _: StoreTransaction? = try await purchases.handleObserverModeTransaction(result) + } + for try await _: CustomerInfo in purchases.customerInfoStream {} #if os(iOS) || VISION_OS diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 95890a34bd..7fe10448fb 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -20,6 +20,19 @@ class StoreKit2IntegrationTests: StoreKit1IntegrationTests { override class var storeKitVersion: StoreKitVersion { return .storeKit2 } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testObservingTransactionThrowsIfObserverModeNotEnabled() async throws { + let manager = ObserverModeManager() + let result = try await manager.purchaseProductFromStoreKit2() + + do { + _ = try await Purchases.shared.handleObserverModeTransaction(result) + fail("Expected error") + } catch { + expect(error).to(matchError(ErrorCode.configurationError)) + } + } + } class StoreKit1IntegrationTests: BaseStoreKitIntegrationTests { diff --git a/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift index 1c457b3222..4c160c6d14 100644 --- a/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift @@ -12,7 +12,7 @@ // Created by Nacho Soto on 12/15/22. import Foundation - +import Nimble @testable import RevenueCat import StoreKit import StoreKitTest @@ -45,25 +45,16 @@ class StoreKit2ObserverModeIntegrationTests: StoreKit1ObserverModeIntegrationTes } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func testPurchaseInDevicePostsReceipt() async throws { + func testObservingTransactionUnlocksEntitlement() async throws { let result = try await self.manager.purchaseProductFromStoreKit2() let transaction = try XCTUnwrap(result.verificationResult?.underlyingTransaction) try self.testSession.disableAutoRenewForTransaction(identifier: UInt(transaction.id)) - XCTExpectFailure("This test currently does not pass (see FB12231111)") - - try await asyncWait( - description: "Entitlement didn't become active", - timeout: .seconds(5), - pollInterval: .milliseconds(500) - ) { - let entitlement = await self.purchasesDelegate - .customerInfo? - .entitlements[Self.entitlementIdentifier] + _ = try await Purchases.shared.handleObserverModeTransaction(result) - return entitlement?.isActive == true - } + let customerInfo = try XCTUnwrap(self.purchasesDelegate.customerInfo) + try await self.verifyEntitlementWentThrough(customerInfo) } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) @@ -186,3 +177,22 @@ class StoreKit1ObserverModeWithExistingPurchasesTests: BaseStoreKitObserverModeI } } + +class StoreKit2NotEnabledObserverModeIntegrationTests: BaseStoreKitObserverModeIntegrationTests { + + override class var storeKitVersion: StoreKitVersion { .storeKit1 } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testObservingTransactionThrowsIfStoreKit2NotEnabled() async throws { + let manager = ObserverModeManager() + let result = try await manager.purchaseProductFromStoreKit2() + + do { + _ = try await Purchases.shared.handleObserverModeTransaction(result) + fail("Expected error") + } catch { + expect(error).to(matchError(ErrorCode.configurationError)) + } + } + +} diff --git a/Tests/UnitTests/Mocks/MockPurchases.swift b/Tests/UnitTests/Mocks/MockPurchases.swift index 39d00ca45e..fe2b9f7d5c 100644 --- a/Tests/UnitTests/Mocks/MockPurchases.swift +++ b/Tests/UnitTests/Mocks/MockPurchases.swift @@ -12,6 +12,7 @@ // Created by Nacho Soto on 10/10/22. @testable import RevenueCat +import StoreKit final class MockPurchases { @@ -474,6 +475,13 @@ extension MockPurchases: PurchasesSwiftType { self.unimplemented() } + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + func handleObserverModeTransaction( + _ purchaseResult: Product.PurchaseResult + ) async throws -> RevenueCat.StoreTransaction? { + self.unimplemented() + } + #if os(iOS) || targetEnvironment(macCatalyst) || VISION_OS @available(iOS 16.0, *) diff --git a/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift index 0b5d667300..d49a8d59fe 100644 --- a/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift +++ b/Tests/UnitTests/Mocks/MockStoreKit2TransactionListener.swift @@ -55,7 +55,8 @@ final class MockStoreKit2TransactionListener: StoreKit2TransactionListenerType { var invokedHandleParametersList = [(purchaseResult: Box, Void)]() func handle( - purchaseResult: StoreKit.Product.PurchaseResult + purchaseResult: StoreKit.Product.PurchaseResult, + fromTransactionUpdate: Bool = false ) async throws -> StoreKit2TransactionListener.ResultData { self.invokedHandle = true self.invokedHandleCount += 1