Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paywalls: warm-up image cache #2978

Merged
merged 2 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -970,6 +971,7 @@
4F6ABC772A81595900250E63 /* PaywallCacheWarming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCacheWarming.swift; sourceTree = "<group>"; };
4F6ABC792A81649800250E63 /* MockPaywallCacheWarming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPaywallCacheWarming.swift; sourceTree = "<group>"; };
4F6ABC7B2A81673F00250E63 /* PaywallCacheWarmingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCacheWarmingTests.swift; sourceTree = "<group>"; };
4F6ABC7D2A81700900250E63 /* PaywallsStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaywallsStrings.swift; sourceTree = "<group>"; };
4F6BED582A26A14400CD9322 /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = "<group>"; };
4F6BEDD82A26B55C00CD9322 /* DebugViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewModel.swift; sourceTree = "<group>"; };
4F6BEDDF2A26B65900CD9322 /* DebugViewSheetPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewSheetPresentation.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
5 changes: 1 addition & 4 deletions Sources/Logging/Strings/EligibilityStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)

}

extension EligibilityStrings: LogMessage {
Expand Down Expand Up @@ -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"
}
}

Expand Down
43 changes: 43 additions & 0 deletions Sources/Logging/Strings/PaywallsStrings.swift
Original file line number Diff line number Diff line change
@@ -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<String>)
case warming_up_images(imageURLs: Set<URL>)
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 cache: \(imageURLs)"

case let .error_prefetching_image(url, error):
return "Error pre-fetching paywall image '\(url)': \((error as NSError).description)"
}
}

var category: String { return "paywalls" }

}
1 change: 1 addition & 0 deletions Sources/Logging/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 94 additions & 7 deletions Sources/Paywalls/PaywallCacheWarming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(
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<Void, Never> {
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<String> {
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<URL> {
return .init(
self
.all
.values
.lazy
.compactMap(\.paywall)
.flatMap(\.allImageURLs)
)
}

}
Expand All @@ -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 }
}

}
1 change: 1 addition & 0 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,7 @@ private extension Purchases {
if let offerings = offerings.value {
self.operationDispatcher.dispatchOnWorkerThread {
cache.warmUpEligibilityCache(offerings: offerings)
cache.warmUpPaywallImagesCache(offerings: offerings)
}
}
}
Expand Down
20 changes: 19 additions & 1 deletion Tests/UnitTests/Mocks/MockPaywallCacheWarming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -34,4 +33,23 @@ final class MockPaywallCacheWarming: PaywallCacheWarmingType {
self.invokedWarmUpEligibilityCacheOfferings = offerings
}

// MARK: -

private let _invokedWarmUpPaywallImagesCache: Atomic<Bool> = false
private let _invokedWarmUpPaywallImagesCacheOfferings: Atomic<Offerings?> = 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
}

}
43 changes: 41 additions & 2 deletions Tests/UnitTests/Paywalls/PaywallCacheWarmingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> = [
"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 {
Expand Down Expand Up @@ -132,3 +156,18 @@ private extension PaywallCacheWarmingTests {
static let offeringIdentifier = "offering"

}

private final class MockPaywallImageFetcher: PaywallImageFetcherType {

let downloadedImages: Atomic<Set<URL>> = .init([])

var images: Set<String> {
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) }
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
}

}