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

PurchasedProductsFetcher: cache current entitlements #2507

Merged
merged 2 commits into from
May 20, 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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ extension DispatchTimeInterval {
self = .milliseconds(Int(timeInterval * 1000))
}

static func minutes(_ minutes: Int) -> Self {
precondition(minutes >= 0, "Minutes must be positive: \(minutes)")

return .seconds(minutes * 60)
}

static func hours(_ hours: Int) -> Self {
precondition(hours >= 0, "Hours must be positive: \(hours)")

Expand Down
12 changes: 12 additions & 0 deletions Sources/Logging/Strings/OfflineEntitlementsStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ enum OfflineEntitlementsStrings {
case computing_offline_customer_info_failed(Error)
case computed_offline_customer_info(EntitlementInfos)

case purchased_products_fetching
case purchased_products_returning_cache(count: Int)
case purchased_products_invalidating_cache

}

extension OfflineEntitlementsStrings: CustomStringConvertible {
Expand Down Expand Up @@ -68,6 +72,14 @@ extension OfflineEntitlementsStrings: CustomStringConvertible {

case let .computed_offline_customer_info(entitlements):
return "Computed offline CustomerInfo with \(entitlements.active.count) active entitlements."
case .purchased_products_fetching:
return "PurchasedProductsFetcher: fetching products from StoreKit"

case let .purchased_products_returning_cache(count):
return "PurchasedProductsFetcher: returning \(count) cached products"

case .purchased_products_invalidating_cache:
return "PurchasedProductsFetcher: invalidating cache"
}
}

Expand Down
21 changes: 14 additions & 7 deletions Sources/OfflineEntitlements/OfflineCustomerInfoCreator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,25 @@ class OfflineCustomerInfoCreator {
private let productEntitlementMappingFetcher: ProductEntitlementMappingFetcher
private let creator: Creator

static func createDefault(
productEntitlementMappingFetcher: ProductEntitlementMappingFetcher
) -> OfflineCustomerInfoCreator? {
static func createPurchasedProductsFetcherIfAvailable() -> PurchasedProductsFetcherType? {
if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) {
return .init(
purchasedProductsFetcher: PurchasedProductsFetcher(),
productEntitlementMappingFetcher: productEntitlementMappingFetcher
)
return PurchasedProductsFetcher()
} else {
return nil
}
}

static func createIfAvailable(
with purchasedProductsFetcher: PurchasedProductsFetcherType?,
productEntitlementMappingFetcher: ProductEntitlementMappingFetcher
) -> OfflineCustomerInfoCreator? {
guard let fetcher = purchasedProductsFetcher else {
Logger.debug(Strings.offlineEntitlements.offline_entitlements_not_available)
return nil
}

return .init(purchasedProductsFetcher: fetcher,
productEntitlementMappingFetcher: productEntitlementMappingFetcher)
}

convenience init(purchasedProductsFetcher: PurchasedProductsFetcherType,
Expand Down
37 changes: 36 additions & 1 deletion Sources/OfflineEntitlements/PurchasedProductsFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,27 @@ protocol PurchasedProductsFetcherType {
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func fetchPurchasedProducts() async throws -> [PurchasedSK2Product]

func clearCache()

}

/// A type that can fetch purchased products from StoreKit 2.
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
class PurchasedProductsFetcher: PurchasedProductsFetcherType {

private typealias Transactions = [StoreKit.VerificationResult<StoreKit.Transaction>]

private let appStoreSync: () async throws -> Void
private let sandboxDetector: SandboxEnvironmentDetector
private let cache: InMemoryCachedObject<Transactions>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you are planning to cache the transactions instead of the whole offline customer info? While that should probably solve the performance concerns it might not help to support fetching customer info from cache while in offline entitlements mode, unless we add support for recalculating it there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might not help to support fetching customer info from cache while in offline entitlements mode, unless we add support for recalculating it there.

The SDK already always returns customer info from the cache regardless of the mode. Is there a particular scenario you think wouldn't work?


init(
appStoreSync: @escaping () async throws -> Void = PurchasedProductsFetcher.defaultAppStoreSync,
sandboxDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector()
) {
self.appStoreSync = appStoreSync
self.sandboxDetector = sandboxDetector
self.cache = .init()
}

func fetchPurchasedProducts() async throws -> [PurchasedSK2Product] {
Expand All @@ -47,7 +53,7 @@ class PurchasedProductsFetcher: PurchasedProductsFetcherType {
syncError = error
}

for await transaction in StoreKit.Transaction.currentEntitlements {
for transaction in await self.transactions {
switch transaction {
case let .unverified(transaction, verificationError):
Logger.appleWarning(
Expand Down Expand Up @@ -75,6 +81,35 @@ class PurchasedProductsFetcher: PurchasedProductsFetcherType {
}
}

func clearCache() {
Logger.debug(Strings.offlineEntitlements.purchased_products_invalidating_cache)

self.cache.clearCache()
}

static let defaultAppStoreSync = AppStore.sync

private static let cacheDuration: DispatchTimeInterval = .minutes(5)

private var transactions: Transactions {
get async {
if !self.cache.isCacheStale(durationInSeconds: Self.cacheDuration.seconds),
let cache = self.cache.cachedInstance, !cache.isEmpty {
Logger.debug(Strings.offlineEntitlements.purchased_products_returning_cache(count: cache.count))
return cache
}

var result: Transactions = []

Logger.debug(Strings.offlineEntitlements.purchased_products_fetching)

for await transaction in StoreKit.Transaction.currentEntitlements {
result.append(transaction)
}

self.cache.cache(instance: result)
return result
}
}

}
27 changes: 20 additions & 7 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
private let productsManager: ProductsManagerType
private let customerInfoManager: CustomerInfoManager
private let trialOrIntroPriceEligibilityChecker: CachingTrialOrIntroPriceEligibilityChecker
private let purchasedProductsFetcher: PurchasedProductsFetcherType?
private let purchasesOrchestrator: PurchasesOrchestrator
private let receiptFetcher: ReceiptFetcher
private let requestFetcher: StoreKitRequestFetcher
Expand Down Expand Up @@ -279,13 +280,21 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
let attributionFetcher = AttributionFetcher(attributionFactory: attributionTypeFactory, systemInfo: systemInfo)
let userDefaults = userDefaults ?? UserDefaults.computeDefault()
let deviceCache = DeviceCache(sandboxEnvironmentDetector: systemInfo, userDefaults: userDefaults)
let backend = Backend(apiKey: apiKey,
systemInfo: systemInfo,
httpClientTimeout: networkTimeout,
eTagManager: eTagManager,
operationDispatcher: operationDispatcher,
attributionFetcher: attributionFetcher,
offlineCustomerInfoCreator: .createDefault(productEntitlementMappingFetcher: deviceCache))

let purchasedProductsFetcher = OfflineCustomerInfoCreator.createPurchasedProductsFetcherIfAvailable()

let backend = Backend(
apiKey: apiKey,
systemInfo: systemInfo,
httpClientTimeout: networkTimeout,
eTagManager: eTagManager,
operationDispatcher: operationDispatcher,
attributionFetcher: attributionFetcher,
offlineCustomerInfoCreator: .createIfAvailable(
with: purchasedProductsFetcher,
productEntitlementMappingFetcher: deviceCache
)
)

let paymentQueueWrapper: EitherPaymentQueueWrapper = systemInfo.storeKit2Setting.shouldOnlyUseStoreKit2
? .right(.init())
Expand Down Expand Up @@ -412,6 +421,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
offeringsManager: offeringsManager,
offlineEntitlementsManager: offlineEntitlementsManager,
purchasesOrchestrator: purchasesOrchestrator,
purchasedProductsFetcher: purchasedProductsFetcher,
trialOrIntroPriceEligibilityChecker: trialOrIntroPriceChecker)
}
// swiftlint:disable:next function_body_length
Expand All @@ -435,6 +445,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
offeringsManager: OfferingsManager,
offlineEntitlementsManager: OfflineEntitlementsManager,
purchasesOrchestrator: PurchasesOrchestrator,
purchasedProductsFetcher: PurchasedProductsFetcherType?,
trialOrIntroPriceEligibilityChecker: TrialOrIntroPriceEligibilityCheckerType
) {

Expand Down Expand Up @@ -481,6 +492,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
self.offeringsManager = offeringsManager
self.offlineEntitlementsManager = offlineEntitlementsManager
self.purchasesOrchestrator = purchasesOrchestrator
self.purchasedProductsFetcher = purchasedProductsFetcher
self.trialOrIntroPriceEligibilityChecker = .create(with: trialOrIntroPriceEligibilityChecker)

super.init()
Expand Down Expand Up @@ -1407,6 +1419,7 @@ private extension Purchases {

func handleCustomerInfoChanged(_ customerInfo: CustomerInfo) {
self.trialOrIntroPriceEligibilityChecker.clearCache()
self.purchasedProductsFetcher?.clearCache()
self.delegate?.purchases?(self, receivedUpdated: customerInfo)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ class PurchasedProductsFetcherTests: BasePurchasedProductsFetcherTests {
]))
}

func testCacheIsInvalidated() async throws {
let product1 = try await self.fetchSk2Product(Self.productID)
_ = try await self.createTransactionWithPurchase(product: product1)

let products1 = try await self.fetcher.fetchPurchasedProducts()
expect(products1).to(haveCount(1))

let product2 = try await self.fetchSk2Product("com.revenuecat.annual_39.99_no_trial")
_ = try await self.createTransactionWithPurchase(product: product2)

self.fetcher.clearCache()

let products2 = try await self.fetcher.fetchPurchasedProducts()
expect(products2).to(haveCount(2))
expect(products2.map(\.productIdentifier)).to(contain([
product1.id,
product2.id
]))
}

}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import XCTest

class DispatchTimeIntervalExtensionsTests: TestCase {

func testMinutes() {
expect(DispatchTimeInterval.minutes(0)) == .seconds(0)
expect(DispatchTimeInterval.minutes(1)) == .seconds(60)
expect(DispatchTimeInterval.minutes(5)) == .seconds(60 * 5)
}

func testHours() {
expect(DispatchTimeInterval.hours(0)) == .seconds(0)
expect(DispatchTimeInterval.hours(1)) == .seconds(3600)
Expand Down
8 changes: 8 additions & 0 deletions Tests/UnitTests/Mocks/MockPurchasedProductsFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,12 @@ final class MockPurchasedProductsFetcher: PurchasedProductsFetcherType {
return try self.stubbedResult.get()
}

var invokedClearCache = false
var invokedClearCacheCount = 0

func clearCache() {
self.invokedClearCache = true
self.invokedClearCacheCount += 1
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ class BasePurchasesTests: TestCase {
offeringsManager: self.mockOfferingsManager,
offlineEntitlementsManager: self.mockOfflineEntitlementsManager,
purchasesOrchestrator: self.purchasesOrchestrator,
purchasedProductsFetcher: self.mockPurchasedProductsFetcher,
trialOrIntroPriceEligibilityChecker: self.cachingTrialOrIntroPriceEligibilityChecker)

self.purchasesOrchestrator.delegate = self.purchases
Expand Down
13 changes: 13 additions & 0 deletions Tests/UnitTests/Purchasing/Purchases/PurchasesLogInTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ class PurchasesLogInTests: BasePurchasesTests {
expect(self.cachingTrialOrIntroPriceEligibilityChecker.invokedClearCacheCount) == 1
}

func testLogInClearsPurchasedProductsFetcherCache() {
expect(self.mockPurchasedProductsFetcher.invokedClearCache) == false

self.identityManager.mockLogInResult = .success((Self.mockLoggedInInfo, true))

waitUntil { completed in
self.purchases.logIn(Self.appUserID) { _, _, _ in completed() }
}

expect(self.mockPurchasedProductsFetcher.invokedClearCache).toEventually(beTrue())
expect(self.mockPurchasedProductsFetcher.invokedClearCacheCount) == 1
}

}

// MARK: -
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class PurchasesSubscriberAttributesTests: TestCase {

var mockOfferingsManager: MockOfferingsManager!
var mockOfflineEntitlementsManager: MockOfflineEntitlementsManager!
var mockPurchasedProductsFetcher: MockPurchasedProductsFetcher!
var mockManageSubsHelper: MockManageSubscriptionsHelper!
var mockBeginRefundRequestHelper: MockBeginRefundRequestHelper!

Expand Down Expand Up @@ -105,6 +106,7 @@ class PurchasesSubscriberAttributesTests: TestCase {
currentUserProvider: self.mockIdentityManager,
attributionPoster: self.mockAttributionPoster)
self.mockOfflineEntitlementsManager = MockOfflineEntitlementsManager()
self.mockPurchasedProductsFetcher = MockPurchasedProductsFetcher()
self.customerInfoManager = CustomerInfoManager(offlineEntitlementsManager: self.mockOfflineEntitlementsManager,
operationDispatcher: self.mockOperationDispatcher,
deviceCache: self.mockDeviceCache,
Expand Down Expand Up @@ -187,6 +189,7 @@ class PurchasesSubscriberAttributesTests: TestCase {
offeringsManager: mockOfferingsManager,
offlineEntitlementsManager: mockOfflineEntitlementsManager,
purchasesOrchestrator: purchasesOrchestrator,
purchasedProductsFetcher: mockPurchasedProductsFetcher,
trialOrIntroPriceEligibilityChecker: trialOrIntroductoryPriceEligibilityChecker)
purchasesOrchestrator.delegate = purchases
purchases!.delegate = purchasesDelegate
Expand Down