diff --git a/Sources/Networking/Responses/OfferingsResponse.swift b/Sources/Networking/Responses/OfferingsResponse.swift index ea6c19a5fb..33a0485938 100644 --- a/Sources/Networking/Responses/OfferingsResponse.swift +++ b/Sources/Networking/Responses/OfferingsResponse.swift @@ -66,6 +66,9 @@ extension OfferingsResponse { ) } + var packages: [Offering.Package] { + return self.offerings.flatMap { $0.packages } + } } extension OfferingsResponse.Offering.Package: Codable, Equatable {} diff --git a/Sources/Purchasing/OfferingsManager.swift b/Sources/Purchasing/OfferingsManager.swift index c2f57f7f95..190eb6265f 100644 --- a/Sources/Purchasing/OfferingsManager.swift +++ b/Sources/Purchasing/OfferingsManager.swift @@ -192,7 +192,7 @@ private extension OfferingsManager { return } - self.productsManager.products(withIdentifiers: productIdentifiers) { result in + self.fetchProducts(withIdentifiers: productIdentifiers, fromResponse: response) { result in let products = result.value ?? [] guard products.isEmpty == false else { @@ -275,6 +275,97 @@ private extension OfferingsManager { } } + private func fetchProducts( + withIdentifiers identifiers: Set, + fromResponse response: OfferingsResponse, + completion: @escaping ProductsManagerType.Completion + ) { + if self.systemInfo.dangerousSettings.uiPreviewMode { + let previewProducts = self.createPreviewProducts(productIdentifiers: identifiers, fromResponse: response) + completion(.success(previewProducts)) + } else { + self.productsManager.products(withIdentifiers: identifiers, completion: completion) + } + } + + // MARK: - For UI Preview mode + + /// Generates a set of dummy `StoreProduct`s with hardcoded information exclusively for UI Preview mode. + private func createPreviewProducts( + productIdentifiers: Set, + fromResponse response: OfferingsResponse + ) -> Set { + let packagesByProductID = response.packages.dictionaryAllowingDuplicateKeys { $0.platformProductIdentifier } + let products = productIdentifiers.map { identifier -> StoreProduct in + let productType = self.inferredPreviewProductType(from: packagesByProductID[identifier], + productIdentifier: identifier) + + let introductoryDiscount: TestStoreProductDiscount? = { + // To allow introductory offers in UI Preview mode, + // all dummy yearly subscriptions have a 1-week free trial + guard productType.period?.unit == .year else { return nil } + return TestStoreProductDiscount( + identifier: "intro", + price: 0, + localizedPriceString: "$0.00", + paymentMode: .freeTrial, + subscriptionPeriod: SubscriptionPeriod(value: 1, unit: .week), + numberOfPeriods: 1, + type: .introductory + ) + }() + + let testProduct = TestStoreProduct( + localizedTitle: "PRO \(productType.type)", + price: Decimal(productType.price), + localizedPriceString: String(format: "$%.2f", productType.price), + productIdentifier: identifier, + productType: productType.period == nil ? .nonConsumable : .autoRenewableSubscription, + localizedDescription: productType.type + (productType.period == nil ? "" : " subscription"), + subscriptionGroupIdentifier: productType.period == nil ? nil : "group", + subscriptionPeriod: productType.period, + introductoryDiscount: introductoryDiscount, + discounts: [] + ) + + return testProduct.toStoreProduct() + } + + return Set(products) + } + + private func inferredPreviewProductType( + from package: OfferingsResponse.Offering.Package?, + productIdentifier: String + ) -> PreviewProductType { + if let package, + let previewProductType = PreviewProductType(packageType: Package.packageType(from: package.identifier)) { + return previewProductType + } else { + // Try to guess basing on the product identifier + let id = productIdentifier.lowercased() + + let packageType: PackageType + if id.contains("lifetime") || id.contains("forever") || id.contains("permanent") { + packageType = .lifetime + } else if id.contains("annual") || id.contains("year") { + packageType = .annual + } else if id.contains("sixmonth") || id.contains("6month") { + packageType = .sixMonth + } else if id.contains("threemonth") || id.contains("3month") || id.contains("quarter") { + packageType = .threeMonth + } else if id.contains("twomonth") || id.contains("2month") { + packageType = .twoMonth + } else if id.contains("month") { + packageType = .monthly + } else if id.contains("week") { + packageType = .weekly + } else { + packageType = .custom + } + return PreviewProductType(packageType: packageType) ?? .default + } + } } extension OfferingsManager { @@ -402,3 +493,53 @@ extension OfferingsManager.Error: CustomNSError { } } + +/// For UI Preview mode only. +private struct PreviewProductType { + let type: String + let price: Double + let period: SubscriptionPeriod? + + static let `default` = PreviewProductType(type: "lifetime", price: 249.99, period: nil) + + private init(type: String, price: Double, period: SubscriptionPeriod?) { + self.type = type + self.price = price + self.period = period + } + + init?(packageType: PackageType) { + switch packageType { + case .lifetime: + self = PreviewProductType(type: "lifetime", + price: 199.99, + period: nil) + case .annual: + self = PreviewProductType(type: "yearly", + price: 59.99, + period: SubscriptionPeriod(value: 1, unit: .year)) + case .sixMonth: + self = PreviewProductType(type: "6 months", + price: 30.99, + period: SubscriptionPeriod(value: 3, unit: .month)) + case .threeMonth: + self = PreviewProductType(type: "3 months", + price: 15.99, + period: SubscriptionPeriod(value: 3, unit: .month)) + case .twoMonth: + self = PreviewProductType(type: "monthly", + price: 11.49, + period: SubscriptionPeriod(value: 2, unit: .month)) + case .monthly: + self = PreviewProductType(type: "monthly", + price: 5.99, + period: SubscriptionPeriod(value: 1, unit: .month)) + case .weekly: + self = PreviewProductType(type: "weekly", + price: 1.99, + period: SubscriptionPeriod(value: 1, unit: .week)) + case .unknown, .custom: + return nil + } + } +} diff --git a/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift b/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift index 60fc38b618..ae0c9f4d3c 100644 --- a/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift +++ b/Tests/UnitTests/Purchasing/OfferingsManagerTests.swift @@ -12,7 +12,7 @@ // Created by Juanpe Catalán on 9/8/21. import Nimble -@testable import RevenueCat +@testable @_spi(Internal) import RevenueCat import StoreKit import XCTest @@ -478,6 +478,69 @@ extension OfferingsManagerTests { level: .error) } + func testProductsManagerIsNotUsedInUIPreviewModeWhenGetOfferingsSuccess() throws { + // given + let mockSystemInfoWithPreviewMode = MockSystemInfo( + platformInfo: .init(flavor: "iOS", version: "3.2.1"), + finishTransactions: true, + dangerousSettings: DangerousSettings(uiPreviewMode: true) + ) + + self.offeringsManager = OfferingsManager( + deviceCache: self.mockDeviceCache, + operationDispatcher: self.mockOperationDispatcher, + systemInfo: mockSystemInfoWithPreviewMode, + backend: self.mockBackend, + offeringsFactory: self.mockOfferingsFactory, + productsManager: self.mockProductsManager + ) + + self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsResponse) + + // when + let result: Result? = waitUntilValue { completed in + self.offeringsManager.offerings(appUserID: MockData.anyAppUserID) { result in + completed(result) + } + } + + // then + expect(result).to(beSuccess()) + expect(self.mockProductsManager.invokedProducts) == false + expect(result?.value?.current?.availablePackages).toNot(beEmpty()) + } + + func testProductsManagerIsNotUsedInUIPreviewModeWhenGetOfferingsFailure() throws { + // given + let mockSystemInfoWithPreviewMode = MockSystemInfo( + platformInfo: .init(flavor: "iOS", version: "3.2.1"), + finishTransactions: true, + dangerousSettings: DangerousSettings(uiPreviewMode: true) + ) + + self.offeringsManager = OfferingsManager( + deviceCache: self.mockDeviceCache, + operationDispatcher: self.mockOperationDispatcher, + systemInfo: mockSystemInfoWithPreviewMode, + backend: self.mockBackend, + offeringsFactory: self.mockOfferingsFactory, + productsManager: self.mockProductsManager + ) + + self.mockOfferings.stubbedGetOfferingsCompletionResult = .failure(MockData.unexpectedBackendResponseError) + + // when + let result: Result? = waitUntilValue { completed in + self.offeringsManager.offerings(appUserID: MockData.anyAppUserID) { result in + completed(result) + } + } + + // then + expect(result).to(beFailure()) + expect(self.mockProductsManager.invokedProducts) == false + } + } private extension OfferingsManagerTests {