From 17d8c748d97f7049386c70a378ea1f30c3842185 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 7 Aug 2023 11:51:48 -0700 Subject: [PATCH 1/2] [move] `Paywalls`: warm-up image cache --- RevenueCat.xcodeproj/project.pbxproj | 4 + .../Logging/Strings/EligibilityStrings.swift | 5 +- Sources/Logging/Strings/PaywallsStrings.swift | 43 ++++++++ Sources/Logging/Strings/Strings.swift | 1 + Sources/Paywalls/PaywallCacheWarming.swift | 101 ++++++++++++++++-- Sources/Purchasing/Purchases/Purchases.swift | 1 + .../Mocks/MockPaywallCacheWarming.swift | 20 +++- .../Paywalls/PaywallCacheWarmingTests.swift | 43 +++++++- .../PurchasesGetOfferingsTests.swift | 7 +- 9 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 Sources/Logging/Strings/PaywallsStrings.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 67abbcd371..b089345d87 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -227,6 +227,7 @@ 4F6ABC782A81595900250E63 /* PaywallCacheWarming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6ABC772A81595900250E63 /* PaywallCacheWarming.swift */; }; 4F6ABC7A2A81649800250E63 /* MockPaywallCacheWarming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6ABC792A81649800250E63 /* MockPaywallCacheWarming.swift */; }; 4F6ABC7C2A81673F00250E63 /* PaywallCacheWarmingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6ABC7B2A81673F00250E63 /* PaywallCacheWarmingTests.swift */; }; + 4F6ABC7E2A81700900250E63 /* PaywallsStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6ABC7D2A81700900250E63 /* PaywallsStrings.swift */; }; 4F6BED592A26A14400CD9322 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6BED582A26A14400CD9322 /* DebugView.swift */; }; 4F6BEDD92A26B55C00CD9322 /* DebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6BEDD82A26B55C00CD9322 /* DebugViewModel.swift */; }; 4F6BEDE02A26B65900CD9322 /* DebugViewSheetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6BEDDF2A26B65900CD9322 /* DebugViewSheetPresentation.swift */; }; @@ -970,6 +971,7 @@ 4F6ABC772A81595900250E63 /* PaywallCacheWarming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCacheWarming.swift; sourceTree = ""; }; 4F6ABC792A81649800250E63 /* MockPaywallCacheWarming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPaywallCacheWarming.swift; sourceTree = ""; }; 4F6ABC7B2A81673F00250E63 /* PaywallCacheWarmingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCacheWarmingTests.swift; sourceTree = ""; }; + 4F6ABC7D2A81700900250E63 /* PaywallsStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaywallsStrings.swift; sourceTree = ""; }; 4F6BED582A26A14400CD9322 /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = ""; }; 4F6BEDD82A26B55C00CD9322 /* DebugViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewModel.swift; sourceTree = ""; }; 4F6BEDDF2A26B65900CD9322 /* DebugViewSheetPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewSheetPresentation.swift; sourceTree = ""; }; @@ -1526,6 +1528,7 @@ 9A65E07A2591977500DE00B0 /* NetworkStrings.swift */, 9A65E09F2591A23200DE00B0 /* OfferingStrings.swift */, 57488BE729CB7FB60000EE7E /* OfflineEntitlementsStrings.swift */, + 4F6ABC7D2A81700900250E63 /* PaywallsStrings.swift */, 9A65E0A42591A23500DE00B0 /* PurchaseStrings.swift */, 5791FCF22992D3EC00F1FEDA /* SigningStrings.swift */, F5C0196826E880800005D61E /* StoreKitStrings.swift */, @@ -3401,6 +3404,7 @@ B34605D1279A6E600031CA74 /* CustomerAPI.swift in Sources */, 2DDF41A224F6F331005BC22D /* ProductsManager.swift in Sources */, 57CCC6EC2984496D001CE9B6 /* Box.swift in Sources */, + 4F6ABC7E2A81700900250E63 /* PaywallsStrings.swift in Sources */, 575137CF27F50D2F0064AB2C /* HTTPResponseBody.swift in Sources */, 573E7F092819B989007C9128 /* StoreKitWorkarounds.swift in Sources */, 5791FCF32992D3EC00F1FEDA /* SigningStrings.swift in Sources */, diff --git a/Sources/Logging/Strings/EligibilityStrings.swift b/Sources/Logging/Strings/EligibilityStrings.swift index cc67d04166..545f480af1 100644 --- a/Sources/Logging/Strings/EligibilityStrings.swift +++ b/Sources/Logging/Strings/EligibilityStrings.swift @@ -24,7 +24,7 @@ enum EligibilityStrings { case check_eligibility_no_identifiers case check_eligibility_failed(productIdentifier: String, error: Error) case sk2_intro_eligibility_too_slow - case warming_up_eligibility_cache(products: Set) + } extension EligibilityStrings: LogMessage { @@ -53,9 +53,6 @@ extension EligibilityStrings: LogMessage { case .sk2_intro_eligibility_too_slow: return "StoreKit 2 intro eligibility took longer than expected to determine" - - case let .warming_up_eligibility_cache(products): - return "Warming up intro eligibility cache for \(products.count) products" } } diff --git a/Sources/Logging/Strings/PaywallsStrings.swift b/Sources/Logging/Strings/PaywallsStrings.swift new file mode 100644 index 0000000000..5e2b5340a8 --- /dev/null +++ b/Sources/Logging/Strings/PaywallsStrings.swift @@ -0,0 +1,43 @@ +// +// 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 +// +// PaywallsStrings.swift +// +// Created by Nacho Soto on 08/7/23. + +import Foundation + +// swiftlint:disable identifier_name + +enum PaywallsStrings { + + case warming_up_eligibility_cache(products: Set) + case warming_up_images(imageURLs: Set) + case error_prefetching_image(URL, Error) + +} + +extension PaywallsStrings: LogMessage { + + var description: String { + switch self { + case let .warming_up_eligibility_cache(products): + return "Warming up intro eligibility cache for \(products.count) products" + + case let .warming_up_images(imageURLs): + return "Warming up paywall images: \(imageURLs)" + + case let .error_prefetching_image(url, error): + return "Error pre-fetching paywall image '\(url)': \((error as NSError).description)" + } + } + + var category: String { return "paywalls" } + +} diff --git a/Sources/Logging/Strings/Strings.swift b/Sources/Logging/Strings/Strings.swift index 84208ff21d..70b240a826 100644 --- a/Sources/Logging/Strings/Strings.swift +++ b/Sources/Logging/Strings/Strings.swift @@ -26,6 +26,7 @@ enum Strings { static let network = NetworkStrings.self static let offering = OfferingStrings.self static let offlineEntitlements = OfflineEntitlementsStrings.self + static let paywalls = PaywallsStrings.self static let purchase = PurchaseStrings.self static let receipt = ReceiptStrings.self static let signing = SigningStrings.self diff --git a/Sources/Paywalls/PaywallCacheWarming.swift b/Sources/Paywalls/PaywallCacheWarming.swift index 2e44d0fe81..b7c83b8060 100644 --- a/Sources/Paywalls/PaywallCacheWarming.swift +++ b/Sources/Paywalls/PaywallCacheWarming.swift @@ -16,30 +16,94 @@ import Foundation protocol PaywallCacheWarmingType: Sendable { func warmUpEligibilityCache(offerings: Offerings) + func warmUpPaywallImagesCache(offerings: Offerings) + +} + +protocol PaywallImageFetcherType: Sendable { + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func downloadImage(_ url: URL) async throws } final class PaywallCacheWarming: PaywallCacheWarmingType { private let introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType + private let imageFetcher: PaywallImageFetcherType + + convenience init(introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType) { + final class Fetcher: PaywallImageFetcherType { + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func downloadImage(_ url: URL) async throws { + _ = try await URLSession.shared.data(from: url) + } + } - init(introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType) { + self.init(introEligibiltyChecker: introEligibiltyChecker, imageFetcher: Fetcher()) + } + + init(introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType, imageFetcher: PaywallImageFetcherType) { self.introEligibiltyChecker = introEligibiltyChecker + self.imageFetcher = imageFetcher + + // SwiftUI's `AsyncImage` uses `URLSession.shared` for internal caching. + URLCache.shared.memoryCapacity = 50_000_000 // 50M + URLCache.shared.diskCapacity = 200_000_000 // 200MB } func warmUpEligibilityCache(offerings: Offerings) { - let productIdentifiers = Set( - offerings + let productIdentifiers = offerings.allProductIdentifiersInPaywalls + guard !productIdentifiers.isEmpty else { return } + + Logger.debug(Strings.paywalls.warming_up_eligibility_cache(products: productIdentifiers)) + self.introEligibiltyChecker.checkEligibility(productIdentifiers: productIdentifiers) { _ in } + } + + func warmUpPaywallImagesCache(offerings: Offerings) { + guard #available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) else { return } + + let imageURLs = offerings.allImagesInPaywalls + guard !imageURLs.isEmpty else { return } + + Logger.verbose(Strings.paywalls.warming_up_images(imageURLs: imageURLs)) + + Task { + for url in imageURLs { + do { + try await self.imageFetcher.downloadImage(url) + } catch { + Logger.error(Strings.paywalls.error_prefetching_image(url, error)) + } + } + } + } + +} + +// MARK: - Extensions + +private extension Offerings { + + var allProductIdentifiersInPaywalls: Set { + return .init( + self .all .values .lazy .flatMap(\.productIdentifiersInPaywall) ) + } - guard !productIdentifiers.isEmpty else { return } - - Logger.debug(Strings.eligibility.warming_up_eligibility_cache(products: productIdentifiers)) - self.introEligibiltyChecker.checkEligibility(productIdentifiers: productIdentifiers) { _ in } + var allImagesInPaywalls: Set { + return .init( + self + .all + .values + .lazy + .compactMap(\.paywall) + .flatMap(\.allImageURLs) + ) } } @@ -59,3 +123,26 @@ private extension Offering { } } + +private extension PaywallData { + + var allImageURLs: [URL] { + return self.config.images + .allImageNames + .map { self.assetBaseURL.appendingPathComponent($0) } + } + +} + +private extension PaywallData.Configuration.Images { + + var allImageNames: [String] { + return [ + self.header, + self.background, + self.icon + ] + .compactMap { $0 } + } + +} diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 9c6376043c..3db0f811bf 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -1624,6 +1624,7 @@ private extension Purchases { if let offerings = offerings.value { self.operationDispatcher.dispatchOnWorkerThread { cache.warmUpEligibilityCache(offerings: offerings) + cache.warmUpPaywallImagesCache(offerings: offerings) } } } diff --git a/Tests/UnitTests/Mocks/MockPaywallCacheWarming.swift b/Tests/UnitTests/Mocks/MockPaywallCacheWarming.swift index 95c2767b27..838cf54989 100644 --- a/Tests/UnitTests/Mocks/MockPaywallCacheWarming.swift +++ b/Tests/UnitTests/Mocks/MockPaywallCacheWarming.swift @@ -23,7 +23,6 @@ final class MockPaywallCacheWarming: PaywallCacheWarmingType { get { return self._invokedWarmUpEligibilityCache.value } set { self._invokedWarmUpEligibilityCache.value = newValue } } - var invokedWarmUpEligibilityCacheOfferings: Offerings? { get { return self._invokedWarmUpEligibilityCacheOfferings.value } set { self._invokedWarmUpEligibilityCacheOfferings.value = newValue } @@ -34,4 +33,23 @@ final class MockPaywallCacheWarming: PaywallCacheWarmingType { self.invokedWarmUpEligibilityCacheOfferings = offerings } + // MARK: - + + private let _invokedWarmUpPaywallImagesCache: Atomic = false + private let _invokedWarmUpPaywallImagesCacheOfferings: Atomic = nil + + var invokedWarmUpPaywallImagesCache: Bool { + get { return self._invokedWarmUpPaywallImagesCache.value } + set { self._invokedWarmUpPaywallImagesCache.value = newValue } + } + var invokedWarmUpPaywallImagesCacheOfferings: Offerings? { + get { return self._invokedWarmUpPaywallImagesCacheOfferings.value } + set { self._invokedWarmUpPaywallImagesCacheOfferings.value = newValue } + } + + func warmUpPaywallImagesCache(offerings: Offerings) { + self.invokedWarmUpPaywallImagesCache = true + self.invokedWarmUpPaywallImagesCacheOfferings = offerings + } + } diff --git a/Tests/UnitTests/Paywalls/PaywallCacheWarmingTests.swift b/Tests/UnitTests/Paywalls/PaywallCacheWarmingTests.swift index 7ee3d03420..95a62e53ac 100644 --- a/Tests/UnitTests/Paywalls/PaywallCacheWarmingTests.swift +++ b/Tests/UnitTests/Paywalls/PaywallCacheWarmingTests.swift @@ -18,13 +18,16 @@ import XCTest class PaywallCacheWarmingTests: TestCase { private var eligibilityChecker: MockTrialOrIntroPriceEligibilityChecker! + private var imageFetcher: MockPaywallImageFetcher! private var cache: PaywallCacheWarmingType! override func setUp() { super.setUp() self.eligibilityChecker = .init() - self.cache = PaywallCacheWarming(introEligibiltyChecker: self.eligibilityChecker) + self.imageFetcher = .init() + self.cache = PaywallCacheWarming(introEligibiltyChecker: self.eligibilityChecker, + imageFetcher: self.imageFetcher) } func testOfferingsWithNoPaywallsDoesNotCheckEligibility() throws { @@ -76,12 +79,33 @@ class PaywallCacheWarmingTests: TestCase { ] self.logger.verifyMessageWasLogged( - Strings.eligibility.warming_up_eligibility_cache(products: ["product_1", "product_3"]), + Strings.paywalls.warming_up_eligibility_cache(products: ["product_1", "product_3"]), level: .debug, expectedCount: 1 ) } + func testWarmsUpImages() throws { + let paywall = try Self.loadPaywall("PaywallData-Sample1") + let offerings = try Self.createOfferings([ + Self.createOffering( + identifier: Self.offeringIdentifier, + paywall: paywall, + products: [] + ) + ]) + + let expectedURLs: Set = [ + "https://rc-paywalls.s3.amazonaws.com/header.jpg", + "https://rc-paywalls.s3.amazonaws.com/background.jpg", + "https://rc-paywalls.s3.amazonaws.com/icon.jpg" + ] + + self.cache.warmUpPaywallImagesCache(offerings: offerings) + + expect(self.imageFetcher.images).toEventually(equal(expectedURLs)) + } + } private extension PaywallCacheWarmingTests { @@ -132,3 +156,18 @@ private extension PaywallCacheWarmingTests { static let offeringIdentifier = "offering" } + +private final class MockPaywallImageFetcher: PaywallImageFetcherType { + + let downloadedImages: Atomic> = .init([]) + + var images: Set { + return Set(self.downloadedImages.value.map(\.absoluteString)) + } + + @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) + func downloadImage(_ url: URL) async throws { + self.downloadedImages.modify { $0.insert(url) } + } + +} diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesGetOfferingsTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesGetOfferingsTests.swift index a3d0523d92..93456d46a1 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesGetOfferingsTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesGetOfferingsTests.swift @@ -99,7 +99,7 @@ class PurchasesGetOfferingsTests: BasePurchasesTests { expect(self.deviceCache.clearOfferingsCacheTimestampCount) == 0 } - func testWarmsUpEligibilityCache() throws { + func testWarmsUpPaywallsCache() throws { let bundle = Bundle(for: Self.self) let offeringsURL = try XCTUnwrap(bundle.url(forResource: "Offerings", withExtension: "json", @@ -126,8 +126,13 @@ class PurchasesGetOfferingsTests: BasePurchasesTests { self.setupPurchases() expect(self.mockOfferingsManager.invokedUpdateOfferingsCacheCount).toEventually(equal(1)) + expect(self.mockOfferingsManager.invokedUpdateOfferingsCacheCount).toEventually(equal(1)) + expect(self.paywallCache.invokedWarmUpEligibilityCache) == true expect(self.paywallCache.invokedWarmUpEligibilityCacheOfferings) == offerings + + expect(self.paywallCache.invokedWarmUpPaywallImagesCache) == true + expect(self.paywallCache.invokedWarmUpPaywallImagesCacheOfferings) == offerings } } From f2a184552869b22d04dc7f4a6c22742941cb3585 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 7 Aug 2023 14:24:25 -0700 Subject: [PATCH 2/2] Improved string --- Sources/Logging/Strings/PaywallsStrings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Logging/Strings/PaywallsStrings.swift b/Sources/Logging/Strings/PaywallsStrings.swift index 5e2b5340a8..da0f2461cc 100644 --- a/Sources/Logging/Strings/PaywallsStrings.swift +++ b/Sources/Logging/Strings/PaywallsStrings.swift @@ -31,7 +31,7 @@ extension PaywallsStrings: LogMessage { return "Warming up intro eligibility cache for \(products.count) products" case let .warming_up_images(imageURLs): - return "Warming up paywall images: \(imageURLs)" + return "Warming up paywall images cache: \(imageURLs)" case let .error_prefetching_image(url, error): return "Error pre-fetching paywall image '\(url)': \((error as NSError).description)"