diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 8be7b58e05..4faaf6e691 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -620,6 +620,7 @@ 6E38843A0CAFD551013D0A3F /* StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECF627761D375C8431EB866 /* StoreProduct.swift */; }; 805B60C97993B311CEC93EAF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3628C1F100BB3C1782860D24 /* ProductsFetcherSK2.swift */; }; 80E80EF226970E04008F245A /* ReceiptFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E80EF026970DC3008F245A /* ReceiptFetcher.swift */; }; + 884D3CE62C08E86400412198 /* PurchasesAreCompletedBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884D3CE52C08E86400412198 /* PurchasesAreCompletedBy.swift */; }; 9A65DFDE258AD60A00DE00B0 /* LogIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A65DFDD258AD60A00DE00B0 /* LogIntent.swift */; }; 9A65E03625918B0500DE00B0 /* ConfigureStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A65E03525918B0500DE00B0 /* ConfigureStrings.swift */; }; 9A65E03B25918B0900DE00B0 /* CustomerInfoStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A65E03A25918B0900DE00B0 /* CustomerInfoStrings.swift */; }; @@ -1364,6 +1365,7 @@ 57FFD2502922DBED00A9A878 /* MockStoreTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreTransaction.swift; sourceTree = ""; }; 80E80EF026970DC3008F245A /* ReceiptFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptFetcher.swift; sourceTree = ""; }; 84C3F1AC1D7E1E64341D3936 /* Pods_RevenueCat_PurchasesTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RevenueCat_PurchasesTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 884D3CE52C08E86400412198 /* PurchasesAreCompletedBy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesAreCompletedBy.swift; sourceTree = ""; }; 9A65DFDD258AD60A00DE00B0 /* LogIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogIntent.swift; sourceTree = ""; }; 9A65E03525918B0500DE00B0 /* ConfigureStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigureStrings.swift; sourceTree = ""; }; 9A65E03A25918B0900DE00B0 /* CustomerInfoStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomerInfoStrings.swift; sourceTree = ""; }; @@ -2853,6 +2855,7 @@ B3843BCA285149A0009F4854 /* Attribution.swift */, 57FD7B1428DA4037009CA4E4 /* PurchasesType.swift */, B35042C326CDB79A00905B95 /* Purchases.swift */, + 884D3CE52C08E86400412198 /* PurchasesAreCompletedBy.swift */, B35042C526CDD3B100905B95 /* PurchasesDelegate.swift */, 2D9F4A5426C30CA800B07B43 /* PurchasesOrchestrator.swift */, 4F8038322A1EA7C300D21039 /* TransactionPoster.swift */, @@ -3535,6 +3538,7 @@ 35AAEB452BBB14D000A12548 /* DiagnosticsFileHandler.swift in Sources */, B34D2AA626976FC700D88C3A /* ErrorCode.swift in Sources */, 4F15B4A12A6774C9005BEFE8 /* CustomerInfo+NonSubscriptions.swift in Sources */, + 884D3CE62C08E86400412198 /* PurchasesAreCompletedBy.swift in Sources */, B39E811D268E887500D31189 /* SubscriberAttribute.swift in Sources */, A5F0104E2717B3150090732D /* BeginRefundRequestHelper.swift in Sources */, B378156D285A9772000A7B93 /* OfferingsAPI.swift in Sources */, diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 4922e2db16..f58b7900bc 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -40,6 +40,11 @@ enum Strings { case restore_purchases_with_empty_result case setting_restored_customer_info + case executing_purchase_logic + case executing_external_purchase_logic + case executing_restore_logic + case executing_external_restore_logic + } @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) @@ -98,6 +103,22 @@ extension Strings: CustomStringConvertible { case .setting_restored_customer_info: return "Setting restored customer info" + + case .executing_external_purchase_logic: + return "Will execute custom StoreKit purchase logic provided by your app. " + + "No StoreKit purchasing logic will be performed by RevenueCat. " + + "You must use `.handlePurchaseAndRestore` on your `PaywallView`." + + case .executing_purchase_logic: + return "Will execute purchase logic provided by RevenueCat." + + case .executing_restore_logic: + return "Will execute restore purchases logic provided by RevenueCat." + + case .executing_external_restore_logic: + return "Will execute custom StoreKit restore purchases logic provided by your app. " + + "No StoreKit restore purchases logic will be performed by RevenueCat. " + + "You must use `.handlePurchaseAndRestore` on your `PaywallView`." } } diff --git a/RevenueCatUI/Helpers/Logger.swift b/RevenueCatUI/Helpers/Logger.swift index 6a44ed961c..bc3840d6cc 100644 --- a/RevenueCatUI/Helpers/Logger.swift +++ b/RevenueCatUI/Helpers/Logger.swift @@ -63,6 +63,21 @@ enum Logger { ) } + static func error( + _ text: CustomStringConvertible, + file: String = #file, + function: String = #function, + line: UInt = #line + ) { + Self.log( + text, + .error, + file: file, + function: function, + line: line + ) + } + private static func log( _ text: CustomStringConvertible, _ level: LogLevel, diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index b1d948bfe0..b3ae37cdcc 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -310,6 +310,10 @@ struct LoadedOfferingPaywallView: View { value: self.purchaseHandler.packageBeingPurchased) .preference(key: PurchasedResultPreferenceKey.self, value: .init(data: self.purchaseHandler.purchaseResult)) + .preference(key: HandlePurchasePreferenceKey.self, + value: self.purchaseHandler.performPurchase) + .preference(key: HandleRestorePreferenceKey.self, + value: self.purchaseHandler.performRestore) .preference(key: RestoredCustomerInfoPreferenceKey.self, value: self.purchaseHandler.restoredCustomerInfo) .preference(key: RestoreInProgressPreferenceKey.self, diff --git a/RevenueCatUI/Purchasing/MockPurchases.swift b/RevenueCatUI/Purchasing/MockPurchases.swift index 6a9afaadd6..200c52be4c 100644 --- a/RevenueCatUI/Purchasing/MockPurchases.swift +++ b/RevenueCatUI/Purchasing/MockPurchases.swift @@ -19,22 +19,38 @@ import RevenueCat @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) final class MockPurchases: PaywallPurchasesType { + typealias CustomerInfoBlock = @Sendable () async throws -> CustomerInfo typealias PurchaseBlock = @Sendable (Package) async throws -> PurchaseResultData typealias RestoreBlock = @Sendable () async throws -> CustomerInfo typealias TrackEventBlock = @Sendable (PaywallEvent) async -> Void + private let customerInfoBlock: CustomerInfoBlock private let purchaseBlock: PurchaseBlock private let restoreBlock: RestoreBlock private let trackEventBlock: TrackEventBlock + private let _purchasesAreCompletedBy: PurchasesAreCompletedBy + + var purchasesAreCompletedBy: PurchasesAreCompletedBy { + get { return _purchasesAreCompletedBy } + set { _ = newValue } + } init( + purchasesAreCompletedBy: PurchasesAreCompletedBy = .revenueCat, purchase: @escaping PurchaseBlock, restorePurchases: @escaping RestoreBlock, - trackEvent: @escaping TrackEventBlock + trackEvent: @escaping TrackEventBlock, + customerInfo: @escaping CustomerInfoBlock ) { self.purchaseBlock = purchase self.restoreBlock = restorePurchases self.trackEventBlock = trackEvent + self.customerInfoBlock = customerInfo + self._purchasesAreCompletedBy = purchasesAreCompletedBy + } + + func customerInfo() async throws -> RevenueCat.CustomerInfo { + return try await self.customerInfoBlock() } func purchase(package: Package) async throws -> PurchaseResultData { @@ -65,6 +81,8 @@ extension PaywallPurchasesType { try await restore(self.restorePurchases)() } trackEvent: { event in await self.track(paywallEvent: event) + } customerInfo: { + try await self.customerInfo() } } @@ -78,6 +96,8 @@ extension PaywallPurchasesType { try await self.restorePurchases() } trackEvent: { event in await trackEvent(self.track(paywallEvent:))(event) + } customerInfo: { + try await self.customerInfo() } } diff --git a/RevenueCatUI/Purchasing/PaywallPurchasesType.swift b/RevenueCatUI/Purchasing/PaywallPurchasesType.swift index 448c23325d..ba88e09d5f 100644 --- a/RevenueCatUI/Purchasing/PaywallPurchasesType.swift +++ b/RevenueCatUI/Purchasing/PaywallPurchasesType.swift @@ -17,12 +17,17 @@ import RevenueCat @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) protocol PaywallPurchasesType: Sendable { + var purchasesAreCompletedBy: PurchasesAreCompletedBy { get set } + @Sendable func purchase(package: Package) async throws -> PurchaseResultData @Sendable func restorePurchases() async throws -> CustomerInfo + @Sendable + func customerInfo() async throws -> CustomerInfo + @Sendable func track(paywallEvent: PaywallEvent) async diff --git a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift index 9f5c111f98..4159585859 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift @@ -19,9 +19,11 @@ import RevenueCat @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension PurchaseHandler { - static func mock(_ customerInfo: CustomerInfo = TestData.customerInfo) -> Self { + static func mock(_ customerInfo: CustomerInfo = TestData.customerInfo, + purchasesAreCompletedBy: PurchasesAreCompletedBy = .revenueCat) + -> Self { return self.init( - purchases: MockPurchases { _ in + purchases: MockPurchases(purchasesAreCompletedBy: purchasesAreCompletedBy) { _ in return ( // No current way to create a mock transaction with RevenueCat's public methods. transaction: nil, @@ -32,12 +34,14 @@ extension PurchaseHandler { return customerInfo } trackEvent: { event in Logger.debug("Tracking event: \(event)") + } customerInfo: { + return customerInfo } ) } - static func cancelling() -> Self { - return .mock() + static func cancelling(purchasesAreCompletedBy: PurchasesAreCompletedBy = .revenueCat) -> Self { + return .mock(purchasesAreCompletedBy: purchasesAreCompletedBy) .map { block in { var result = try await block($0) result.userCancelled = true @@ -55,6 +59,8 @@ extension PurchaseHandler { throw error } trackEvent: { event in Logger.debug("Tracking event: \(event)") + } customerInfo: { + throw error } ) } diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index e08bb25198..6941c04ca4 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -15,6 +15,59 @@ import RevenueCat import StoreKit import SwiftUI +// swiftlint:disable file_length + +/// A class that can be used to report the result of a purchase. +public class PurchaseResultReporter: Equatable { + + let storeProduct: StoreProduct + let reportPurchaseResultCallback: (_ userCancelled: Bool, _ error: Error?) -> Void + + init(storeProduct: StoreProduct, reportPurchaseResult: @escaping (_: Bool, _: Error?) -> Void) { + self.storeProduct = storeProduct + self.reportPurchaseResultCallback = reportPurchaseResult + } + + /// Use this method to report the result of the purchase. + /// - Parameters: + /// - userCancelled: A boolean indicating whether the user cancelled the purchase. + /// - error: An optional error object if an error occurred during the purchase. + public func reportResult(userCancelled: Bool, error: Error?) { + reportPurchaseResultCallback(userCancelled, error) + } + + /// Checks whether two `PurchaseResultReporter` instances are equal. + /// They are considered equal if the object represents the same `StoreProduct` + public static func == (lhs: PurchaseResultReporter, rhs: PurchaseResultReporter) -> Bool { + return lhs.storeProduct == rhs.storeProduct + } + +} + +/// A class that can be used to report the result of a restoring purchases. +public class RestoreResultReporter: Equatable { + + let reportRestoreResultCallback: (_ success: Bool, _ error: Error?) -> Void + + init(callback: @escaping (Bool, Error?) -> Void) { + self.reportRestoreResultCallback = callback + } + + /// Use this method to report the result of a restore operation. + /// - Parameters: + /// - success: A boolean indicating whether the restore operation was successful. + /// - error: An optional error object if an error occurred during the restore operation. + public func reportResult(success: Bool, error: Error?) { + reportRestoreResultCallback(success, error) + } + + /// Returns true if objects are the same object (same memory address); false otherwise. + public static func == (lhs: RestoreResultReporter, rhs: RestoreResultReporter) -> Bool { + return lhs === rhs + } + +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) // @PublicForExternalTesting final class PurchaseHandler: ObservableObject { @@ -40,6 +93,14 @@ final class PurchaseHandler: ObservableObject { @Published fileprivate(set) var purchaseResult: PurchaseResultData? + /// Information used to perform a purchase by the app (rather than by RevenueCat) + @Published + fileprivate(set) var performPurchase: PurchaseResultReporter? + + /// Information used to perform restoring a purchase by the app (rather than by RevenueCat) + @Published + fileprivate(set) var performRestore: RestoreResultReporter? + /// Whether a restore is currently in progress @Published fileprivate(set) var restoreInProgress: Bool = false @@ -58,6 +119,8 @@ final class PurchaseHandler: ObservableObject { private var eventData: PaywallEvent.Data? + private var externalRestorePurchaseContinuation: CheckedContinuation? + convenience init(purchases: Purchases = .shared) { self.init(isConfigured: true, purchases: purchases) } @@ -70,6 +133,8 @@ final class PurchaseHandler: ObservableObject { self.purchases = purchases } + /// Returns a new instance of `PurchaseHandler` using `Purchases.shared` if `Purchases` + /// has been configured, and using a PurchaseHandler that cannot be used for purchases otherwise. // @PublicForExternalTesting static func `default`() -> Self { return Purchases.isConfigured ? .init() : Self.notConfigured() @@ -84,8 +149,21 @@ final class PurchaseHandler: ObservableObject { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension PurchaseHandler { + // MARK: - Purchase + @MainActor func purchase(package: Package) async throws -> PurchaseResultData { + switch self.purchases.purchasesAreCompletedBy { + case .revenueCat: + return try await performPurchase(package: package) + case .myApp: + return try await performExternalPurchaseLogic(package: package) + } + } + + @MainActor + func performPurchase(package: Package) async throws -> PurchaseResultData { + Logger.debug(Strings.executing_purchase_logic) self.packageBeingPurchased = package self.purchaseResult = nil self.purchaseError = nil @@ -115,13 +193,58 @@ extension PurchaseHandler { } } + @MainActor + func performExternalPurchaseLogic(package: Package) async throws -> PurchaseResultData { + Logger.debug(Strings.executing_external_purchase_logic) + + self.packageBeingPurchased = package + self.purchaseResult = nil + self.purchaseError = nil + self.performPurchase = PurchaseResultReporter(storeProduct: package.storeProduct, + reportPurchaseResult: self.reportExternalPurchaseResult) + + self.startAction() + + return PurchaseResultData(nil, try await self.purchases.customerInfo(), false) + } + + @MainActor + func reportExternalPurchaseResult(_ userCancelled: Bool, _ error: Error?) { + self.actionInProgress = false + self.performPurchase = nil + + if let error { + self.purchaseError = error + } else { + if userCancelled { + self.trackCancelledPurchase() + } else { + withAnimation(Constants.defaultAnimation) { + self.purchased = true + } + } + } + } + + // MARK: - Restore + + func restorePurchases() async throws -> (info: CustomerInfo, success: Bool) { + switch self.purchases.purchasesAreCompletedBy { + case .revenueCat: + return try await performRestorePurchases() + case .myApp: + return try await performExternalRestoreLogic() + } + } + /// - Returns: `success` is `true` only when the resulting `CustomerInfo` /// had any transactions /// - Note: `restoredCustomerInfo` will be not be set after this method, /// instead `setRestored(_:)` must be manually called afterwards. /// This allows the UI to display an alert before dismissing the paywall. @MainActor - func restorePurchases() async throws -> (info: CustomerInfo, success: Bool) { + func performRestorePurchases() async throws -> (info: CustomerInfo, success: Bool) { + Logger.debug(Strings.executing_restore_logic) self.restoreInProgress = true self.restoredCustomerInfo = nil self.restoreError = nil @@ -143,6 +266,45 @@ extension PurchaseHandler { } } + @MainActor + func performExternalRestoreLogic() async throws -> (info: CustomerInfo, success: Bool) { + Logger.debug(Strings.executing_external_restore_logic) + + defer { + self.restoreInProgress = false + self.actionInProgress = false + } + + self.restoreInProgress = true + self.restoredCustomerInfo = nil + self.restoreError = nil + + DispatchQueue.main.async { + // this triggers the view's `.handlePurchaseAndRestore` function, and its callback must be called + // after the continuation is set below + self.performRestore = RestoreResultReporter(callback: self.reportExternalRestoreResult) + } + + self.startAction() + + let success = try await withCheckedThrowingContinuation { continuation in + externalRestorePurchaseContinuation = continuation + } + + return (info: try await self.purchases.customerInfo(), success) + } + + @MainActor + func reportExternalRestoreResult(success: Bool, error: Error?) { + if let error { + self.restoreError = error + externalRestorePurchaseContinuation?.resume(throwing: error) + } else { + externalRestorePurchaseContinuation?.resume(returning: success) + } + externalRestorePurchaseContinuation = nil + } + @MainActor func setRestored(_ customerInfo: CustomerInfo) { self.restoredCustomerInfo = customerInfo @@ -231,6 +393,15 @@ private extension PurchaseHandler { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private final class NotConfiguredPurchases: PaywallPurchasesType { + var purchasesAreCompletedBy: PurchasesAreCompletedBy { + get { return .myApp } + set { _ = newValue } + } + + func customerInfo() async throws -> RevenueCat.CustomerInfo { + throw ErrorCode.configurationError + } + func purchase(package: Package) async throws -> PurchaseResultData { throw ErrorCode.configurationError } @@ -267,6 +438,28 @@ struct RestoreInProgressPreferenceKey: PreferenceKey { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct HandlePurchasePreferenceKey: PreferenceKey { + + static var defaultValue: PurchaseResultReporter? + + static func reduce(value: inout PurchaseResultReporter?, nextValue: () -> PurchaseResultReporter?) { + value = nextValue() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct HandleRestorePreferenceKey: PreferenceKey { + + static var defaultValue: RestoreResultReporter? + + static func reduce(value: inout RestoreResultReporter?, nextValue: () -> RestoreResultReporter?) { + value = nextValue() + } + +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct PurchasedResultPreferenceKey: PreferenceKey { diff --git a/RevenueCatUI/View+PurchaseRestoreCompleted.swift b/RevenueCatUI/View+PurchaseRestoreCompleted.swift index 9b679f477e..f1b03da62d 100644 --- a/RevenueCatUI/View+PurchaseRestoreCompleted.swift +++ b/RevenueCatUI/View+PurchaseRestoreCompleted.swift @@ -14,6 +14,10 @@ import RevenueCat import SwiftUI +import StoreKit + +// swiftlint:disable file_length + /// A closure used for notifying of purchase or restore completion. public typealias PurchaseOrRestoreCompletedHandler = @MainActor @Sendable (CustomerInfo) -> Void @@ -35,6 +39,17 @@ public typealias PurchaseOfPackageStartedHandler = @MainActor @Sendable (_ packa /// A closure used for notifying of purchase cancellation. public typealias PurchaseCancelledHandler = @MainActor @Sendable () -> Void +/// A closure used for notifying that custom purchase logic has completed. +public typealias PerformPurchase = @MainActor @Sendable ( + _ storeProduct: StoreProduct, + _ purchaseResultReporter: PurchaseResultReporter +) -> Void + +/// A closure used for notifying that custom restore logic has completed. +public typealias PerformRestore = @MainActor @Sendable ( + _ restoreResultReporter: RestoreResultReporter +) -> Void + /// A closure used for notifying of failures during purchases or restores. public typealias PurchaseFailureHandler = @MainActor @Sendable (NSError) -> Void @@ -270,6 +285,44 @@ extension View { self.environment(\.onRequestedDismissal, action) } + /// Use this method if you wish to execute your own StoreKit purchase and restore logic, + /// skipping RevenueCat's. This method is **only** called if `Purchases` is + /// confiugured with `purchasesAreCompletedBy` set to `.myApp`. This is typically used + /// when migrating from a direct StoreKit implementation to RevenueCat in stages, or if integrating + /// RevenueCat for experiments and growth tools only. + /// + /// After executing your StoreKit purchase code, you **must** communicate the result of your purchase + /// code by calling `reportResult`on the passed result reporter object when your code + /// has finished executing. Failure to do so will result in undefined behavior. + /// + /// Example: + /// ```swift + /// PaywallView() + /// .handlePurchaseAndRestore( + /// performPurchase: { storeProduct, purchaseResultReporter in + /// // make purchase for `storeProduct` + /// + /// // report result to RevenueCat + /// purchaseResultReporter.reportResult(userCancelled: false, error: nil) + /// }, performRestore: { restoreResultReporter in + /// // restore purchases + /// + /// // report result to RevenueCat + /// restoreResultReporter.reportResult(success: true, error: nil) + /// }) + /// ``` + /// + public func handlePurchaseAndRestore( + performPurchase: @escaping PerformPurchase, + performRestore: @escaping PerformRestore + ) -> some View { + return self.modifier( + HandlePurchaseAndRestoreModifier( + performPurchase: performPurchase, + performRestore: performRestore + ) + ) + } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @@ -332,6 +385,53 @@ private struct OnPurchaseCancelledModifier: ViewModifier { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct HandlePurchaseAndRestoreModifier: ViewModifier { + let performPurchase: PerformPurchase + let performRestore: PerformRestore + + func body(content: Content) -> some View { + content + .modifier(HandlePurchaseModifier(handler: performPurchase)) + .modifier(HandleRestoreModifier(handler: performRestore)) + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct HandlePurchaseModifier: ViewModifier { + + let handler: PerformPurchase + + func body(content: Content) -> some View { + content + .onPreferenceChange(HandlePurchasePreferenceKey.self) { purchaseResultReporter in + if let purchaseResultReporter { + self.handler(purchaseResultReporter.storeProduct, purchaseResultReporter) + } + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct HandleRestoreModifier: ViewModifier { + + let handler: PerformRestore + + func body(content: Content) -> some View { + content + .onPreferenceChange(HandleRestorePreferenceKey.self) { restoreResultReporter in + if let restoreResultReporter { + self.handler(restoreResultReporter) + } else { + Logger.error("Change to `HandleRestorePreferenceKey` with a value of `nil` means " + + "the performPurchase modifier cannot be called.") + } + } + } + +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private struct OnRestoreStartedModifier: ViewModifier { diff --git a/RevenueCatUI/Views/LoadingPaywallView.swift b/RevenueCatUI/Views/LoadingPaywallView.swift index bb3a0d784b..09161fe0fc 100644 --- a/RevenueCatUI/Views/LoadingPaywallView.swift +++ b/RevenueCatUI/Views/LoadingPaywallView.swift @@ -144,6 +144,15 @@ private extension LoadingPaywallView { @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) private final class LoadingPaywallPurchases: PaywallPurchasesType { + var purchasesAreCompletedBy: PurchasesAreCompletedBy { + get { return .myApp } + set { _ = newValue } + } + + func customerInfo() async throws -> RevenueCat.CustomerInfo { + fatalError("Should not be able to purchase") + } + func purchase(package: Package) async throws -> PurchaseResultData { fatalError("Should not be able to purchase") } diff --git a/Sources/Purchasing/Configuration.swift b/Sources/Purchasing/Configuration.swift index 46724c081a..5dfb77ae11 100644 --- a/Sources/Purchasing/Configuration.swift +++ b/Sources/Purchasing/Configuration.swift @@ -86,7 +86,15 @@ import Foundation private(set) var apiKey: String private(set) var appUserID: String? - private(set) var observerMode: Bool = false + var observerMode: Bool { + switch purchasesAreCompletedBy { + case .revenueCat: + return false + case .myApp: + return true + } + } + private(set) var purchasesAreCompletedBy: PurchasesAreCompletedBy = .revenueCat private(set) var userDefaults: UserDefaults? private(set) var dangerousSettings: DangerousSettings? private(set) var networkTimeout = Configuration.networkTimeoutDefault @@ -143,12 +151,26 @@ import Foundation * RevenueCat's backend. Default is `false`. * * - Warning: This assumes your IAP implementation uses StoreKit 1. - * Observer mode is not compatible with StoreKit 2. + * `.myApp` is not compatible with StoreKit 2. */ + @available(*, deprecated, message: "Use with(purchasesAreCompletedBy:) instead.") @objc public func with(observerMode: Bool) -> Configuration.Builder { - self.observerMode = observerMode + self.purchasesAreCompletedBy = observerMode ? .myApp : .revenueCat return self } + /** + * Set `purchasesAreCompletedBy`. + * - Parameter purchasesAreCompletedBy: Set this to `.myApp` if you have your own IAP implementation and + * want to use only RevenueCat's backend. Default is `.revenueCat`. + * + * - Warning: This assumes your IAP implementation uses StoreKit 1. + * `.myApp` is not compatible with StoreKit 2. + */ + @objc public func with(purchasesAreCompletedBy: PurchasesAreCompletedBy) -> Configuration.Builder { + self.purchasesAreCompletedBy = purchasesAreCompletedBy + return self + } + /** * Set `userDefaults`. * - Parameter userDefaults: Custom `UserDefaults` to use diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 6b7297b5c8..65aa1b1e9f 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -228,11 +228,17 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void @objc public let attribution: Attribution + @available(*, deprecated, message: "Use purchasesAreCompletedBy instead.") @objc public var finishTransactions: Bool { get { self.systemInfo.finishTransactions } set { self.systemInfo.finishTransactions = newValue } } + @objc public var purchasesAreCompletedBy: PurchasesAreCompletedBy { + get { self.systemInfo.finishTransactions ? .revenueCat : .myApp } + set { self.systemInfo.finishTransactions = (newValue == .revenueCat ? true : false) } + } + private let attributionFetcher: AttributionFetcher private let attributionPoster: AttributionPoster private let backend: Backend @@ -1225,7 +1231,7 @@ public extension Purchases { * ```swift * Purchases.configure( * with: Configuration.Builder(withAPIKey: Constants.apiKey) - * .with(observerMode: false) + * .with(purchasesAreCompletedBy: .revenueCat) * .with(appUserID: "") * .build() * ) @@ -1266,7 +1272,7 @@ public extension Purchases { * ```swift * Purchases.configure( * with: .init(withAPIKey: Constants.apiKey) - * .with(observerMode: false) + * .with(purchasesAreCompletedBy: .revenueCat) * .with(appUserID: "") * ) * ``` @@ -1319,7 +1325,7 @@ public extension Purchases { @_disfavoredOverload @objc(configureWithAPIKey:appUserID:) @discardableResult static func configure(withAPIKey apiKey: String, appUserID: String?) -> Purchases { - Self.configure(withAPIKey: apiKey, appUserID: appUserID, observerMode: false) + Self.configure(withAPIKey: apiKey, appUserID: appUserID, purchasesAreCompletedBy: .revenueCat) } @available(*, deprecated, message: """ @@ -1356,15 +1362,54 @@ public extension Purchases { * Observer mode is not compatible with StoreKit 2. */ @_disfavoredOverload + @available(*, deprecated, message: "Use configure(withAPIKey:appUserID:purchasesAreCompletedBy:) instead.") @objc(configureWithAPIKey:appUserID:observerMode:) @discardableResult static func configure(withAPIKey apiKey: String, appUserID: String?, observerMode: Bool) -> Purchases { + let purchasesBy: PurchasesAreCompletedBy = observerMode ? .myApp : .revenueCat + + return Self.configure( + with: Configuration + .builder(withAPIKey: apiKey) + .with(appUserID: appUserID) + .with(purchasesAreCompletedBy: purchasesBy) + .build() + ) + } + + /** + * Configures an instance of the Purchases SDK with a custom `UserDefaults`. + * + * Use this constructor if you want to + * sync status across a shared container, such as between a host app and an extension. The instance of the + * Purchases SDK will be set as a singleton. + * You should access the singleton instance using ``Purchases/shared`` + * + * - Parameter apiKey: The API Key generated for your app from https://app.revenuecat.com/ + * + * - Parameter appUserID: The unique app user id for this user. This user id will allow users to share their + * purchases and subscriptions across devices. Pass `nil` or an empty string if you want ``Purchases`` + * to generate this for you. + * + * - Parameter purchasesAreCompletedBy: Set this to `.myApp` if you have your own IAP implementation + * and want to use only RevenueCat's backend. Default is `.revenueCat`. + * + * - Returns: An instantiated ``Purchases`` object that has been set as a singleton. + * + * - Warning: This assumes your IAP implementation uses StoreKit 1. + * Observer mode is not compatible with StoreKit 2. + */ + @_disfavoredOverload + @objc(configureWithAPIKey:appUserID:purchasesAreCompletedBy:) + @discardableResult static func configure(withAPIKey apiKey: String, + appUserID: String?, + purchasesAreCompletedBy: PurchasesAreCompletedBy) -> Purchases { Self.configure( with: Configuration .builder(withAPIKey: apiKey) .with(appUserID: appUserID) - .with(observerMode: observerMode) + .with(purchasesAreCompletedBy: purchasesAreCompletedBy) .build() ) } @@ -1379,11 +1424,13 @@ public extension Purchases { appUserID: StaticString, observerMode: Bool) -> Purchases { Logger.warn(Strings.identity.logging_in_with_static_string) + let purchasesBy: PurchasesAreCompletedBy = observerMode ? .myApp : .revenueCat + return Self.configure( with: Configuration .builder(withAPIKey: apiKey) .with(appUserID: "\(appUserID)") - .with(observerMode: observerMode) + .with(purchasesAreCompletedBy: purchasesBy) .build() ) } diff --git a/Sources/Purchasing/Purchases/PurchasesAreCompletedBy.swift b/Sources/Purchasing/Purchases/PurchasesAreCompletedBy.swift new file mode 100644 index 0000000000..193a209148 --- /dev/null +++ b/Sources/Purchasing/Purchases/PurchasesAreCompletedBy.swift @@ -0,0 +1,28 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesAreCompletedBy.swift +// +// Created by James Borthwick on 2024-05-30. + +import Foundation + +/// Where responsibility for completing purchase transactions lies. +@objc(RCPurchasesAreCompletedBy) +public enum PurchasesAreCompletedBy: Int { + + /// Purchase transactions are to be finished by RevenueCat. + case revenueCat + + /// Purchase transactions are to be finished by the app. + case myApp + +} + +extension PurchasesAreCompletedBy: Sendable {} diff --git a/Sources/Purchasing/Purchases/PurchasesType.swift b/Sources/Purchasing/Purchases/PurchasesType.swift index 9c151f7dc1..1fb4419a45 100644 --- a/Sources/Purchasing/Purchases/PurchasesType.swift +++ b/Sources/Purchasing/Purchases/PurchasesType.swift @@ -37,8 +37,18 @@ public protocol PurchasesType: AnyObject { * will turn up every time the app is opened. * More information on finishing transactions manually [is available here](https://rev.cat/finish-transactions). */ + @available(*, deprecated, message: "Use purchasesAreCompletedBy instead.") var finishTransactions: Bool { get set } + /** Controls if purchaess should be made and transactions finished automatically by RevenueCat. + * `.revenueCat` by default. + * - Warning: Setting this value to `.myApp` will prevent the SDK from making purchaes and finishing transactions. + * In this case, you *must* perform all of this logic in your app. If using a `PaywallView`, use the modifier + * `.handlePurchaseAndRestore`. + * More information on finishing transactions manually [is available here](https://rev.cat/finish-transactions). + */ + var purchasesAreCompletedBy: PurchasesAreCompletedBy { get set } + /** * Delegate for ``Purchases`` instance. The delegate is responsible for handling promotional product purchases and * changes to customer information. diff --git a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/ConfigurationAPI.swift b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/ConfigurationAPI.swift index e1f1c2001a..92842bfb26 100644 --- a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/ConfigurationAPI.swift +++ b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/ConfigurationAPI.swift @@ -13,7 +13,7 @@ func checkConfigurationAPI() { .builder(withAPIKey: "") .with(apiKey: "") .with(appUserID: nil) - .with(observerMode: false) + .with(purchasesAreCompletedBy: .myApp) .with(userDefaults: UserDefaults.standard) .with(dangerousSettings: DangerousSettings()) .with(dangerousSettings: DangerousSettings(autoSyncPurchases: true)) diff --git a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift index 0260fdeeed..5dda28905f 100644 --- a/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift +++ b/Tests/APITesters/CustomEntitlementComputationSwiftAPITester/SwiftAPITester/PurchasesAPI.swift @@ -19,12 +19,12 @@ func checkPurchasesAPI() { let purch = checkConfigure()! // initializers - let finishTransactions: Bool = purch.finishTransactions + let purchasesAreCompletedBy: PurchasesAreCompletedBy = purch.purchasesAreCompletedBy let delegate: PurchasesDelegate? = purch.delegate let appUserID: String = purch.appUserID let isAnonymous: Bool = purch.isAnonymous - print(finishTransactions, delegate!, appUserID, isAnonymous) + print(purchasesAreCompletedBy, delegate!, appUserID, isAnonymous) checkStaticMethods() checkIdentity(purchases: purch) diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m index a7f751a127..a697d4147f 100644 --- a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCConfigurationAPI.m @@ -14,7 +14,7 @@ @implementation RCConfigurationAPI + (void)checkAPI { RCConfigurationBuilder *builder = [RCConfiguration builderWithAPIKey:@""]; RCConfiguration *config __unused = [[[[[[[[[[[builder withApiKey:@""] - withObserverMode:false] + withPurchasesAreCompletedBy:RCPurchasesAreCompletedByRevenueCat] withUserDefaults:NSUserDefaults.standardUserDefaults] withAppUserID:@""] withAppUserID:nil] @@ -25,6 +25,8 @@ + (void)checkAPI { withUsesStoreKit2IfAvailable:false] build]; + RCConfiguration *configDeprecated __unused = [[[builder withApiKey:@""] withObserverMode:true] build]; + if (@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)) { RCConfiguration *config __unused = [[builder withEntitlementVerificationMode:RCEntitlementVerificationModeInformational] build]; diff --git a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCPurchasesAPI.m b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCPurchasesAPI.m index 09e4d3a503..62ee73bd97 100644 --- a/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCPurchasesAPI.m +++ b/Tests/APITesters/ObjCAPITester/ObjCAPITester/RCPurchasesAPI.m @@ -25,6 +25,7 @@ @implementation RCPurchasesAPI BOOL isConfigured; BOOL allowSharingAppStoreAccount; BOOL finishTransactions; +RCPurchasesAreCompletedBy purchasesAreCompletedBy; id delegate; NSString *appUserID; BOOL isAnonymous; @@ -41,6 +42,10 @@ + (void)checkAPI { [RCPurchases configureWithAPIKey:@"" appUserID:nil observerMode:false userDefaults:nil]; [RCPurchases configureWithAPIKey:@"" appUserID:@"" observerMode:false userDefaults:[[NSUserDefaults alloc] init]]; [RCPurchases configureWithAPIKey:@"" appUserID:nil observerMode:false userDefaults:[[NSUserDefaults alloc] init]]; + [RCPurchases configureWithAPIKey:@"" appUserID:@"" purchasesAreCompletedBy:RCPurchasesAreCompletedByRevenueCat]; + [RCPurchases configureWithAPIKey:@"" appUserID:nil purchasesAreCompletedBy:RCPurchasesAreCompletedByRevenueCat]; + [RCPurchases configureWithAPIKey:@"" appUserID:@"" purchasesAreCompletedBy:RCPurchasesAreCompletedByMyApp]; + [RCPurchases configureWithAPIKey:@"" appUserID:nil purchasesAreCompletedBy:RCPurchasesAreCompletedByMyApp]; [RCPurchases configureWithAPIKey:@"" appUserID:nil observerMode:false @@ -96,6 +101,8 @@ + (void)checkAPI { allowSharingAppStoreAccount = [p allowSharingAppStoreAccount]; finishTransactions = [p finishTransactions]; + purchasesAreCompletedBy = [p purchasesAreCompletedBy]; + delegate = [p delegate]; appUserID = [p appUserID]; isAnonymous = [p isAnonymous]; @@ -232,7 +239,7 @@ + (void)checkEnums { case RCLogLevelInfo: case RCLogLevelWarn: case RCLogLevelError: - NSLog(@"%ld", (long)o); + NSLog(@"%ld", (long)l); } RCStoreMessageType smt = RCStoreMessageTypeBillingIssue; @@ -240,7 +247,14 @@ + (void)checkEnums { case RCStoreMessageTypeBillingIssue: case RCStoreMessageTypePriceIncreaseConsent: case RCStoreMessageTypeGeneric: - NSLog(@"%ld", (long)o); + NSLog(@"%ld", (long)smt); + } + + RCPurchasesAreCompletedBy pacb = RCPurchasesAreCompletedByRevenueCat; + switch(pacb) { + case RCPurchasesAreCompletedByMyApp: + case RCPurchasesAreCompletedByRevenueCat: + NSLog(@"%ld", (long)pacb); } } diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/ConfigurationAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/ConfigurationAPI.swift index 9b149eaade..a0eaa6e0bd 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/ConfigurationAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/ConfigurationAPI.swift @@ -13,7 +13,7 @@ func checkConfigurationAPI() { .builder(withAPIKey: "") .with(apiKey: "") .with(appUserID: nil) - .with(observerMode: true) + .with(purchasesAreCompletedBy: .myApp) .with(userDefaults: UserDefaults.standard) .with(dangerousSettings: DangerousSettings()) .with(dangerousSettings: DangerousSettings(autoSyncPurchases: true)) diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift index 57f7ebbcb6..cc70e7ab4c 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift @@ -19,12 +19,12 @@ func checkPurchasesAPI() { let purch = checkConfigure()! // initializers - let finishTransactions: Bool = purch.finishTransactions + let purchasesAreCompletedBy: PurchasesAreCompletedBy = purch.purchasesAreCompletedBy let delegate: PurchasesDelegate? = purch.delegate let appUserID: String = purch.appUserID let isAnonymous: Bool = purch.isAnonymous - print(finishTransactions, delegate!, appUserID, isAnonymous) + print(purchasesAreCompletedBy, delegate!, appUserID, isAnonymous) checkStaticMethods() checkIdentity(purchases: purch) @@ -306,7 +306,7 @@ private func checkConfigure() -> Purchases! { Purchases.configure(withAPIKey: "") Purchases.configure(withAPIKey: "", appUserID: nil) - Purchases.configure(withAPIKey: "", appUserID: nil, observerMode: true) + Purchases.configure(withAPIKey: "", appUserID: nil, purchasesAreCompletedBy: .myApp) return nil } @@ -339,6 +339,7 @@ private func checkDeprecatedMethods(_ purchases: Purchases) { Purchases.addAttributionData([String: Any](), from: AttributionNetwork.adjust, forNetworkUserId: nil) let _: Bool = Purchases.automaticAppleSearchAdsAttributionCollection Purchases.automaticAppleSearchAdsAttributionCollection = false + purchases.finishTransactions = true purchases.checkTrialOrIntroDiscountEligibility([String]()) { (_: [String: IntroEligibility]) in } @@ -350,6 +351,7 @@ private func checkDeprecatedMethods(_ purchases: Purchases) { Purchases.configure(withAPIKey: "", appUserID: nil, observerMode: true, userDefaults: UserDefaults()) Purchases.configure(withAPIKey: "", appUserID: "") Purchases.configure(withAPIKey: "", appUserID: "", observerMode: false) + Purchases.configure(withAPIKey: "", appUserID: nil, observerMode: true) Purchases.configure(withAPIKey: "", appUserID: nil, observerMode: true, @@ -373,4 +375,9 @@ private func checkDeprecatedMethods(_ purchases: Purchases) { userDefaults: UserDefaults(), useStoreKit2IfAvailable: true, dangerousSettings: DangerousSettings(autoSyncPurchases: false)) + + _ = Configuration + .builder(withAPIKey: "") + .with(observerMode: true) + } diff --git a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift index 26bfe3cb61..ea3bf57810 100644 --- a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift +++ b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift @@ -190,6 +190,60 @@ class PurchaseCompletedHandlerTests: TestCase { expect(error).toEventually(matchError(Self.failureError)) } + func testHandleExternalPurchaseAndRestore() throws { + var completed = false + var customPurchaseCodeExecuted = false + + try PaywallView( + offering: Self.offering.withLocalImages, + customerInfo: TestData.customerInfo, + introEligibility: .producing(eligibility: .eligible), + purchaseHandler: Self.externalPurchaseHandler + ) + .handlePurchaseAndRestore(performPurchase: { _, purchaseResultReporter in + purchaseResultReporter.reportResult(userCancelled: false, error: nil) + customPurchaseCodeExecuted = true + }, performRestore: { _ in + + }) + .addToHierarchy() + + Task { + _ = try await Self.externalPurchaseHandler.purchase(package: Self.package) + completed = true + } + + expect(completed).toEventually(beTrue()) + expect(customPurchaseCodeExecuted) == true + } + + func testHandleExternalRestore() throws { + var completed = false + var customRestoreCodeExecuted = false + + try PaywallView( + offering: Self.offering.withLocalImages, + customerInfo: TestData.customerInfo, + introEligibility: .producing(eligibility: .eligible), + purchaseHandler: Self.externalPurchaseHandler + ) + .handlePurchaseAndRestore(performPurchase: { _, _ in + + }, performRestore: { restoreResultReporter in + restoreResultReporter.reportResult(success: true, error: nil) + customRestoreCodeExecuted = true + }) + .addToHierarchy() + + Task { + _ = try await Self.externalPurchaseHandler.restorePurchases() + completed = true + } + + expect(completed).toEventually(beTrue()) + expect(customRestoreCodeExecuted) == true + } + func testOnRestoreStarted() throws { var started = false @@ -255,6 +309,7 @@ class PurchaseCompletedHandlerTests: TestCase { expect(error).toEventually(matchError(Self.failureError)) } + private static let externalPurchaseHandler: PurchaseHandler = .mock(purchasesAreCompletedBy: .myApp) private static let purchaseHandler: PurchaseHandler = .mock() private static let failingHandler: PurchaseHandler = .failing(failureError) private static let offering = TestData.offeringWithNoIntroOffer diff --git a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift index 03c0b28ef2..38d179a427 100644 --- a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift @@ -246,6 +246,11 @@ private final class AsyncPurchaseHandler { return TestData.customerInfo } trackEvent: { event in Logger.debug("Tracking event: \(event)") + } customerInfo: { [weak instance = self] in + let instance = try XCTUnwrap(instance) + await instance.createAndWaitForContinuation() + + return TestData.customerInfo } ) } diff --git a/Tests/UnitTests/Mocks/MockPurchases.swift b/Tests/UnitTests/Mocks/MockPurchases.swift index 1c22140f28..39d00ca45e 100644 --- a/Tests/UnitTests/Mocks/MockPurchases.swift +++ b/Tests/UnitTests/Mocks/MockPurchases.swift @@ -132,6 +132,12 @@ extension MockPurchases: PurchasesType { set { self.unimplemented() } } + var purchasesAreCompletedBy: PurchasesAreCompletedBy { + get { self.unimplemented() } + // swiftlint:disable:next unused_setter_value + set { self.unimplemented() } + } + var delegate: PurchasesDelegate? { get { self.unimplemented() } // swiftlint:disable:next unused_setter_value diff --git a/Tests/UnitTests/Purchasing/ConfigurationTests.swift b/Tests/UnitTests/Purchasing/ConfigurationTests.swift index 3e0ed70bc7..c8092f384b 100644 --- a/Tests/UnitTests/Purchasing/ConfigurationTests.swift +++ b/Tests/UnitTests/Purchasing/ConfigurationTests.swift @@ -52,9 +52,9 @@ class ConfigurationTests: TestCase { self.logger.verifyMessageWasNotLogged(Strings.configure.observer_mode_with_storekit2) } - func testObserverModeWithStoreKit1() { + func testPurchasesAreCompletedByMyAppWithStoreKit1() { let configuration = Configuration.Builder(withAPIKey: "test") - .with(observerMode: true) + .with(purchasesAreCompletedBy: .myApp) .build() expect(configuration.observerMode) == true @@ -64,9 +64,9 @@ class ConfigurationTests: TestCase { } @available(*, deprecated) - func testObserverModeWithStoreKit2() { + func testPurchasesAreCompletedByMyAppWithStoreKit2() { let configuration = Configuration.Builder(withAPIKey: "test") - .with(observerMode: true) + .with(purchasesAreCompletedBy: .myApp) .with(usesStoreKit2IfAvailable: true) .build() diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesConfiguringTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesConfiguringTests.swift index e7c85755f7..b0bf1ffa51 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesConfiguringTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesConfiguringTests.swift @@ -504,8 +504,8 @@ class PurchasesConfiguringTests: BasePurchasesTests { // MARK: - OfflineCustomerInfoCreator - func testObserverModeDoesNotCreateOfflineCustomerInfoCreator() { - expect(Self.create(observerMode: true).offlineCustomerInfoEnabled) == false + func testPurchaesAreCompletedByMyAppDoesNotCreateOfflineCustomerInfoCreator() { + expect(Self.create(purchasesAreCompletedBy: .myApp).offlineCustomerInfoEnabled) == false } func testOlderVersionsDoNoCreateOfflineCustomerInfo() throws { @@ -513,19 +513,19 @@ class PurchasesConfiguringTests: BasePurchasesTests { throw XCTSkip("Test for older versions") } - expect(Self.create(observerMode: false).offlineCustomerInfoEnabled) == false + expect(Self.create(purchasesAreCompletedBy: .revenueCat).offlineCustomerInfoEnabled) == false } func testOfflineCustomerInfoEnabled() throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() - expect(Self.create(observerMode: false).offlineCustomerInfoEnabled) == true + expect(Self.create(purchasesAreCompletedBy: .revenueCat).offlineCustomerInfoEnabled) == true } - private static func create(observerMode: Bool) -> Purchases { + private static func create(purchasesAreCompletedBy: PurchasesAreCompletedBy) -> Purchases { return Purchases.configure( with: .init(withAPIKey: "") - .with(observerMode: observerMode) + .with(purchasesAreCompletedBy: purchasesAreCompletedBy) ) } diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift index 3c95356752..ea38b3fd5d 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesPurchasingTests.swift @@ -214,7 +214,7 @@ class PurchasesPurchasingTests: BasePurchasesTests { } func testDoesntFinishTransactionsIfFinishingDisabled() throws { - self.purchases.finishTransactions = false + self.purchases.purchasesAreCompletedBy = .myApp let product = StoreProduct(sk1Product: MockSK1Product(mockProductIdentifier: "com.product.id1")) self.purchases.purchase(product: product) { (_, _, _, _) in }