From d4989bbeeac40db8c75306756b0f0f9e1157c0c3 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Thu, 20 Jul 2023 13:10:43 +0200 Subject: [PATCH 1/3] `Paywalls`: `TrialOrIntroEligibilityChecker.eligibility(for packages:)` This will be used for the multi-package template. --- RevenueCatUI/Data/TestData.swift | 4 ++-- .../TrialOrIntroEligibilityChecker.swift | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index c4ec4e6fce..782d482678 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -164,10 +164,10 @@ internal enum TestData { extension TrialOrIntroEligibilityChecker { /// Creates a mock `TrialOrIntroEligibilityChecker` with a constant result. - static func producing(eligibility: IntroEligibilityStatus) -> Self { + static func producing(eligibility: @autoclosure @escaping () -> IntroEligibilityStatus) -> Self { return .init { product in return product.hasIntroDiscount - ? eligibility + ? eligibility() : .noIntroOfferExists } } diff --git a/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift b/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift index b13ea06a27..4bad726e2c 100644 --- a/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift +++ b/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift @@ -43,6 +43,25 @@ extension TrialOrIntroEligibilityChecker { return await self.eligibility(for: package.storeProduct) } + /// Computes eligibility for a list of packages in parallel, returning them all in a dictionary. + func eligibility(for packages: [Package]) async -> [Package: IntroEligibilityStatus] { + return await withTaskGroup(of: (Package, IntroEligibilityStatus).self) { group in + for package in packages { + group.addTask { + return (package, await self.eligibility(for: package)) + } + } + + var result: [Package: IntroEligibilityStatus] = [:] + + for await (package, status) in group { + result[package] = status + } + + return result + } + } + } @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) From c640d7e1949b618eb2060f72290d868b1ae2727e Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 21 Jul 2023 00:27:14 +0200 Subject: [PATCH 2/3] [move] `Purchases.checkTrialOrIntroDiscountEligibility(packages: [Package])` This new overload helps the implementation of `RevenueCatUI`, so we can get a complete dictionary of all requested `Packages`, regardless of whether they might refer to the same products, or if the implementation does not contain that in the result. I also implemented `IntroEligibility`'s `Hashable`'s overrides as well as `debugDescription`, which were needed to debug this. Turns out there was no test coverage for the other check trial eligibility methods, so this tests those indirectly as well. --- Sources/Purchasing/IntroEligibility.swift | 30 +++++++++++++ Sources/Purchasing/Purchases/Purchases.swift | 12 +++++ .../SwiftAPITester/PurchasesAPI.swift | 3 ++ .../Purchases/PurchasesGetProductsTests.swift | 45 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/Sources/Purchasing/IntroEligibility.swift b/Sources/Purchasing/IntroEligibility.swift index e4b7473954..d44421f953 100644 --- a/Sources/Purchasing/IntroEligibility.swift +++ b/Sources/Purchasing/IntroEligibility.swift @@ -128,6 +128,19 @@ private extension IntroEligibilityStatus { self.status = .unknown } + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Self else { return false } + + return other.status == self.status + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.status) + + return hasher.finalize() + } + } extension IntroEligibility { @@ -147,6 +160,23 @@ extension IntroEligibility { } } + public override var debugDescription: String { + let name = "\(type(of: self))" + + switch self.status { + case .eligible: + return "\(name).eligible" + case .ineligible: + return "\(name).ineligible" + case .noIntroOfferExists: + return "\(name).noIntroOfferExists" + case .unknown: + return "\(name).unknown" + @unknown default: + return "Unknown" + } + } + } extension IntroEligibility: Sendable {} diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 0bc3c73d8e..243098d339 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -891,6 +891,18 @@ public extension Purchases { return await checkTrialOrIntroductoryDiscountEligibilityAsync(productIdentifiers) } + @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) + func checkTrialOrIntroDiscountEligibility(packages: [Package]) async -> [Package: IntroEligibility] { + let result = await self.checkTrialOrIntroDiscountEligibility( + productIdentifiers: packages.map(\.storeProduct.productIdentifier) + ) + + return Set(packages) + .dictionaryWithValues { (package: Package) in + result[package.storeProduct.productIdentifier] ?? .init(eligibilityStatus: .unknown) + } + } + @objc(checkTrialOrIntroDiscountEligibilityForProduct:completion:) func checkTrialOrIntroDiscountEligibility(product: StoreProduct, completion: @escaping (IntroEligibilityStatus) -> Void) { diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift index c184323ef4..90acd6adbd 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift @@ -218,6 +218,9 @@ private func checkAsyncMethods(purchases: Purchases) async { let _: [String: IntroEligibility] = await purchases.checkTrialOrIntroDiscountEligibility( productIdentifiers: [String]() ) + let _: [Package: IntroEligibility] = await purchases.checkTrialOrIntroDiscountEligibility( + packages: [Package]() + ) let _: PromotionalOffer = try await purchases.promotionalOffer( forProductDiscount: discount, product: stp diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift index cf03354cc0..a852a33d39 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift @@ -55,6 +55,51 @@ class PurchasesGetProductsTests: BasePurchasesTests { ) == true } + @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) + func testGetEligibilityForPackages() async throws { + try AvailabilityChecks.iOS13APIAvailableOrSkipTest() + + let packages: [Package] = [ + .init(identifier: "package1", + packageType: .weekly, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product1")), + offeringIdentifier: "offering"), + .init(identifier: "package2", + packageType: .monthly, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product2")), + offeringIdentifier: "offering"), + .init(identifier: "package3", + packageType: .annual, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product3")), + offeringIdentifier: "offering"), + .init(identifier: "package4", + packageType: .annual, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product4")), + offeringIdentifier: "offering"), + .init(identifier: "package5", + packageType: .annual, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product1")), + offeringIdentifier: "offering") + ] + + self.trialOrIntroPriceEligibilityChecker + .stubbedCheckTrialOrIntroPriceEligibilityFromOptimalStoreReceiveEligibilityResult = [ + "product1": .init(eligibilityStatus: .eligible), + "product2": .init(eligibilityStatus: .noIntroOfferExists), + "product3": .init(eligibilityStatus: .ineligible) + ] + + let result = await self.purchases.checkTrialOrIntroDiscountEligibility(packages: packages) + + expect(result) == [ + packages[0]: .init(eligibilityStatus: .eligible), + packages[1]: .init(eligibilityStatus: .noIntroOfferExists), + packages[2]: .init(eligibilityStatus: .ineligible), + packages[3]: .init(eligibilityStatus: .unknown), + packages[4]: .init(eligibilityStatus: .eligible) + ] + } + } class PurchasesGetProductsBackgroundTests: BasePurchasesTests { From c3a5c027b13d42bf6e5906747e6d6e53161a2113 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 21 Jul 2023 00:32:03 +0200 Subject: [PATCH 3/3] `Paywalls`: optimized `TrialOrIntroEligibilityChecker` --- RevenueCatUI/Data/TestData.swift | 15 ++++++--- .../TrialOrIntroEligibilityChecker.swift | 33 ++++--------------- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index 782d482678..9eb64d0df0 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -165,10 +165,17 @@ extension TrialOrIntroEligibilityChecker { /// Creates a mock `TrialOrIntroEligibilityChecker` with a constant result. static func producing(eligibility: @autoclosure @escaping () -> IntroEligibilityStatus) -> Self { - return .init { product in - return product.hasIntroDiscount - ? eligibility() - : .noIntroOfferExists + return .init { packages in + return Dictionary( + uniqueKeysWithValues: Set(packages) + .map { package in + let result = package.storeProduct.hasIntroDiscount + ? eligibility() + : .noIntroOfferExists + + return (package, result) + } + ) } } diff --git a/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift b/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift index 4bad726e2c..2e9f11f9cc 100644 --- a/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift +++ b/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift @@ -11,17 +11,14 @@ import RevenueCat @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) final class TrialOrIntroEligibilityChecker: ObservableObject { - typealias Checker = @Sendable (StoreProduct) async -> IntroEligibilityStatus + typealias Checker = @Sendable ([Package]) async -> [Package: IntroEligibilityStatus] let checker: Checker convenience init(purchases: Purchases = .shared) { - self.init { product in - guard product.hasIntroDiscount else { - return .noIntroOfferExists - } - - return await purchases.checkTrialOrIntroDiscountEligibility(product: product) + self.init { + return await purchases.checkTrialOrIntroDiscountEligibility(packages: $0) + .mapValues(\.status) } } @@ -35,31 +32,13 @@ final class TrialOrIntroEligibilityChecker: ObservableObject { @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) extension TrialOrIntroEligibilityChecker { - func eligibility(for product: StoreProduct) async -> IntroEligibilityStatus { - return await self.checker(product) - } - func eligibility(for package: Package) async -> IntroEligibilityStatus { - return await self.eligibility(for: package.storeProduct) + return await self.eligibility(for: [package])[package] ?? .unknown } /// Computes eligibility for a list of packages in parallel, returning them all in a dictionary. func eligibility(for packages: [Package]) async -> [Package: IntroEligibilityStatus] { - return await withTaskGroup(of: (Package, IntroEligibilityStatus).self) { group in - for package in packages { - group.addTask { - return (package, await self.eligibility(for: package)) - } - } - - var result: [Package: IntroEligibilityStatus] = [:] - - for await (package, status) in group { - result[package] = status - } - - return result - } + return await self.checker(packages) } }