Skip to content

Commit

Permalink
Purchases: don't clear intro eligibility / purchased products cache…
Browse files Browse the repository at this point in the history
… on first launch (#3067)

These caches are important, especially for `RevenueCatUI`.
Without this fix, launching the app was doing this:
- Pre-warming cache
- Pre-warming cache a second time (to be fixed by a separate PR)
- Clearing cache

Which meant that the cache wasn't really warm when launching paywalls.

To fix that, this only clears the cache after receiving an actual
change.

I've also removed
`CustomerInfoManager.sendCachedCustomerInfoIfAvailable` because it
wasn't used.
  • Loading branch information
NachoSoto authored Aug 30, 2023
1 parent f700100 commit cdcd1a0
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 99 deletions.
41 changes: 21 additions & 20 deletions Sources/Identity/CustomerInfoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ class CustomerInfoManager {

typealias CustomerInfoCompletion = @MainActor @Sendable (Result<CustomerInfo, BackendError>) -> Void

var lastSentCustomerInfo: CustomerInfo? { return self.data.value.lastSentCustomerInfo }

private let offlineEntitlementsManager: OfflineEntitlementsManager
private let operationDispatcher: OperationDispatcher
private let backend: Backend
Expand Down Expand Up @@ -100,14 +98,6 @@ class CustomerInfoManager {
}
}

func sendCachedCustomerInfoIfAvailable(appUserID: String) {
guard let info = self.cachedCustomerInfo(appUserID: appUserID) else {
return
}

self.sendUpdateIfChanged(customerInfo: info)
}

// swiftlint:disable:next function_body_length
func customerInfo(
appUserID: String,
Expand Down Expand Up @@ -215,7 +205,12 @@ class CustomerInfoManager {
func clearCustomerInfoCache(forAppUserID appUserID: String) {
self.modifyData {
$0.deviceCache.clearCustomerInfoCache(appUserID: appUserID)
$0.lastSentCustomerInfo = nil
}
}

func setLastSentCustomerInfo(_ info: CustomerInfo) {
self.modifyData {
$0.lastSentCustomerInfo = info
}
}

Expand All @@ -226,15 +221,17 @@ class CustomerInfoManager {
continuation.yield(lastSentCustomerInfo)
}

let disposable = self.monitorChanges { continuation.yield($0) }
let disposable = self.monitorChanges { _, new in continuation.yield(new) }

continuation.onTermination = { @Sendable _ in disposable() }
}
}

typealias CustomerInfoChangeClosure = (_ old: CustomerInfo?, _ new: CustomerInfo) -> Void

/// Allows monitoring changes to the active `CustomerInfo`.
/// - Returns: closure that removes the created observation.
func monitorChanges(_ changes: @escaping (CustomerInfo) -> Void) -> () -> Void {
func monitorChanges(_ changes: @escaping CustomerInfoChangeClosure) -> () -> Void {
self.modifyData {
let lastIdentifier = $0.customerInfoObserversByIdentifier.keys
.sorted()
Expand All @@ -251,18 +248,22 @@ class CustomerInfoManager {
}
}

// Visible for tests
var lastSentCustomerInfo: CustomerInfo? { return self.data.value.lastSentCustomerInfo }

private func removeObserver(with identifier: Int) {
self.modifyData {
$0.customerInfoObserversByIdentifier.removeValue(forKey: identifier)
}
}

private func sendUpdateIfChanged(customerInfo: CustomerInfo) {
self.modifyData {
guard !$0.customerInfoObserversByIdentifier.isEmpty,
$0.lastSentCustomerInfo != customerInfo else {
return
}
return self.modifyData {
let lastSentCustomerInfo = $0.lastSentCustomerInfo

guard !$0.customerInfoObserversByIdentifier.isEmpty, lastSentCustomerInfo != customerInfo else {
return
}

if $0.lastSentCustomerInfo != nil {
Logger.debug(Strings.customerInfo.sending_updated_customerinfo_to_delegate)
Expand All @@ -276,7 +277,7 @@ class CustomerInfoManager {
// this class' data. By making it async, the closure is invoked outside of the lock.
self.operationDispatcher.dispatchAsyncOnMainThread { [observers = $0.customerInfoObserversByIdentifier] in
for closure in observers.values {
closure(customerInfo)
closure(lastSentCustomerInfo, customerInfo)
}
}
}
Expand Down Expand Up @@ -419,7 +420,7 @@ private extension CustomerInfoManager {
/// This allows cancelling observations by deleting them from this dictionary.
/// These observers are used both for ``Purchases/customerInfoStream`` and
/// `PurchasesDelegate/purchases(_:receivedUpdated:)``.
var customerInfoObserversByIdentifier: [Int: (CustomerInfo) -> Void]
var customerInfoObserversByIdentifier: [Int: CustomerInfoManager.CustomerInfoChangeClosure]

init(deviceCache: DeviceCache) {
self.deviceCache = deviceCache
Expand Down
16 changes: 10 additions & 6 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,9 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
(self as DeprecatedSearchAdsAttribution).postAppleSearchAddsAttributionCollectionIfNeeded()
#endif

self.customerInfoObservationDisposable = customerInfoManager.monitorChanges { [weak self] customerInfo in
self.customerInfoObservationDisposable = customerInfoManager.monitorChanges { [weak self] old, new in
guard let self = self else { return }
self.handleCustomerInfoChanged(customerInfo)
self.handleCustomerInfoChanged(from: old, to: new)
}
}

Expand Down Expand Up @@ -1478,10 +1478,13 @@ internal extension Purchases {

private extension Purchases {

func handleCustomerInfoChanged(_ customerInfo: CustomerInfo) {
self.trialOrIntroPriceEligibilityChecker.clearCache()
self.purchasedProductsFetcher?.clearCache()
self.delegate?.purchases?(self, receivedUpdated: customerInfo)
func handleCustomerInfoChanged(from old: CustomerInfo?, to new: CustomerInfo) {
if old != nil {
self.trialOrIntroPriceEligibilityChecker.clearCache()
self.purchasedProductsFetcher?.clearCache()
}

self.delegate?.purchases?(self, receivedUpdated: new)
}

@objc func applicationWillEnterForeground() {
Expand Down Expand Up @@ -1595,6 +1598,7 @@ private extension Purchases {
}

self.delegate?.purchases?(self, receivedUpdated: info)
self.customerInfoManager.setLastSentCustomerInfo(info)
}

private func updateOfferingsCache(isAppBackgrounded: Bool) {
Expand Down
112 changes: 56 additions & 56 deletions Tests/UnitTests/Identity/CustomerInfoManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ class BaseCustomerInfoManagerTests: TestCase {
var mockTransactionPoster: MockTransactionPoster!

var mockCustomerInfo: CustomerInfo!
var mockCustomerInfo2: CustomerInfo!

var customerInfoManager: CustomerInfoManager!

fileprivate var customerInfoManagerChangesCallCount = 0
fileprivate var customerInfoManagerLastCustomerInfo: CustomerInfo?
fileprivate var customerInfoManagerLastCustomerInfoChange: (old: CustomerInfo?, new: CustomerInfo)?

fileprivate var customerInfoMonitorDisposable: (() -> Void)?

Expand All @@ -37,14 +38,24 @@ class BaseCustomerInfoManagerTests: TestCase {
"original_application_version": NSNull()
] as [String: Any]
])
self.mockCustomerInfo2 = try CustomerInfo(data: [
"request_date": "2020-12-21T02:40:36Z",
"subscriber": [
"original_app_user_id": "another_user",
"first_seen": "2020-06-17T16:05:33Z",
"subscriptions": [:] as [String: Any],
"other_purchases": [:] as [String: Any],
"original_application_version": NSNull()
] as [String: Any]
])

self.mockOfflineEntitlementsManager = MockOfflineEntitlementsManager()
self.mockDeviceCache = MockDeviceCache(sandboxEnvironmentDetector: self.mockSystemInfo)
self.mockTransationFetcher = MockStoreKit2TransactionFetcher()
self.mockTransactionPoster = MockTransactionPoster()

self.customerInfoManagerChangesCallCount = 0
self.customerInfoManagerLastCustomerInfo = nil
self.customerInfoManagerLastCustomerInfoChange = nil

self.customerInfoManager = CustomerInfoManager(
offlineEntitlementsManager: self.mockOfflineEntitlementsManager,
Expand Down Expand Up @@ -74,9 +85,9 @@ class CustomerInfoManagerTests: BaseCustomerInfoManagerTests {
override func setUpWithError() throws {
try super.setUpWithError()

self.customerInfoMonitorDisposable = self.customerInfoManager.monitorChanges { [weak self] customerInfo in
self.customerInfoMonitorDisposable = self.customerInfoManager.monitorChanges { [weak self] old, new in
self?.customerInfoManagerChangesCallCount += 1
self?.customerInfoManagerLastCustomerInfo = customerInfo
self?.customerInfoManagerLastCustomerInfoChange = (old, new)
}
}

Expand Down Expand Up @@ -142,7 +153,7 @@ class CustomerInfoManagerTests: BaseCustomerInfoManagerTests {

expect(self.mockDeviceCache.cacheCustomerInfoCount) == 1
expect(self.customerInfoManagerChangesCallCount) == 1
expect(self.customerInfoManagerLastCustomerInfo) == self.mockCustomerInfo
expect(self.customerInfoManagerLastCustomerInfoChange) == (old: nil, new: self.mockCustomerInfo)
}

func testFetchAndCacheCustomerInfoCallsCompletionOnMainThread() throws {
Expand Down Expand Up @@ -196,51 +207,10 @@ class CustomerInfoManagerTests: BaseCustomerInfoManagerTests {
expect(self.mockBackend.invokedGetSubscriberDataCount) == 1
}

func testSendCachedCustomerInfoIfAvailableForAppUserIDSendsIfNeverSent() throws {
let info: CustomerInfo = .emptyInfo

let object = try info.jsonEncodedData
self.mockDeviceCache.cachedCustomerInfo[Self.appUserID] = object

customerInfoManager.sendCachedCustomerInfoIfAvailable(appUserID: Self.appUserID)

expect(self.customerInfoManagerChangesCallCount).toEventually(equal(1))
}

func testSendCachedCustomerInfoIfAvailableForAppUserIDSendsIfDifferent() throws {
let oldInfo: CustomerInfo = .emptyInfo

var object = try oldInfo.jsonEncodedData

mockDeviceCache.cachedCustomerInfo[Self.appUserID] = object

customerInfoManager.sendCachedCustomerInfoIfAvailable(appUserID: Self.appUserID)

let newInfo = try CustomerInfo(data: [
"request_date": "2019-08-16T10:30:42Z",
"subscriber": [
"original_app_user_id": Self.appUserID,
"first_seen": "2019-06-17T16:05:33Z",
"subscriptions": ["product_a": ["expires_date": "2018-05-27T06:24:50Z", "period_type": "normal"]],
"other_purchases": [:] as [String: Any]
] as [String: Any]
])

object = try newInfo.jsonEncodedData
mockDeviceCache.cachedCustomerInfo[Self.appUserID] = object

customerInfoManager.sendCachedCustomerInfoIfAvailable(appUserID: Self.appUserID)
expect(self.customerInfoManagerChangesCallCount).toEventually(equal(2))
}

func testSendCachedCustomerInfoIfAvailableForAppUserIDSendsOnMainThread() throws {
let oldInfo: CustomerInfo = .emptyInfo

let object = try oldInfo.jsonEncodedData
mockDeviceCache.cachedCustomerInfo[Self.appUserID] = object

customerInfoManager.sendCachedCustomerInfoIfAvailable(appUserID: Self.appUserID)
expect(self.mockOperationDispatcher.invokedDispatchAsyncOnMainThreadCount) == 1
func testSetLastSentCustomerInfo() {
expect(self.customerInfoManager.lastSentCustomerInfo).to(beNil())
self.customerInfoManager.setLastSentCustomerInfo(self.mockCustomerInfo)
expect(self.customerInfoManager.lastSentCustomerInfo) === self.mockCustomerInfo
}

func testCustomerInfoReturnsFromCacheIfAvailable() {
Expand Down Expand Up @@ -448,15 +418,45 @@ class CustomerInfoManagerTests: BaseCustomerInfoManagerTests {
func testCacheCustomerInfoSendsToDelegateIfChanged() {
self.customerInfoManager.cache(customerInfo: self.mockCustomerInfo, appUserID: "myUser")
expect(self.customerInfoManagerChangesCallCount).toEventually(equal(1))
expect(self.customerInfoManagerLastCustomerInfo) == self.mockCustomerInfo
expect(self.customerInfoManagerLastCustomerInfoChange) == (old: nil, new: self.mockCustomerInfo)
}

func testCacheCustomerInfoSendsMultipleUpdatesIfChange() throws {
let newCustomerInfo = try CustomerInfo(data: [
"request_date": "2023-12-21T02:40:36Z",
"subscriber": [
"original_app_user_id": "new user",
"first_seen": "2019-06-17T16:05:33Z",
"subscriptions": [:] as [String: Any],
"other_purchases": [:] as [String: Any],
"original_application_version": NSNull()
] as [String: Any]
])

self.customerInfoManager.cache(customerInfo: self.mockCustomerInfo, appUserID: "myUser")
self.customerInfoManager.cache(customerInfo: newCustomerInfo, appUserID: "myUser")

expect(self.customerInfoManagerChangesCallCount).toEventually(equal(2))
expect(self.customerInfoManagerLastCustomerInfoChange) == (old: self.mockCustomerInfo, new: newCustomerInfo)
}

func testCacheCustomerInfoSendsToDelegateWhenComputedOnDevice() {
let info = self.mockCustomerInfo.copy(with: .verifiedOnDevice)

self.customerInfoManager.cache(customerInfo: info, appUserID: "myUser")
expect(self.customerInfoManagerChangesCallCount).toEventually(equal(1))
expect(self.customerInfoManagerLastCustomerInfo) == info
expect(self.customerInfoManagerLastCustomerInfoChange) == (old: nil, new: info)
}

func testCacheCustomerInfoSendsToDelegateAfterCachingComputedOnDevice() {
let info1 = self.mockCustomerInfo.copy(with: .verifiedOnDevice)
let info2 = self.mockCustomerInfo2.copy(with: .verifiedOnDevice)

self.customerInfoManager.cache(customerInfo: info1, appUserID: info1.originalAppUserId)
self.customerInfoManager.cache(customerInfo: info2, appUserID: info2.originalAppUserId)

expect(self.customerInfoManagerChangesCallCount).toEventually(equal(2))
expect(self.customerInfoManagerLastCustomerInfoChange) == (old: info1, new: info2)
}

func testClearCustomerInfoCacheClearsCorrectly() {
Expand All @@ -466,14 +466,14 @@ class CustomerInfoManagerTests: BaseCustomerInfoManagerTests {
expect(self.mockDeviceCache.invokedClearCustomerInfoCacheParameters?.appUserID) == appUserID
}

func testClearCustomerInfoCacheResetsLastSent() {
func testClearCustomerInfoCacheDoesNotResetLastSent() {
let appUserID = "myUser"
customerInfoManager.cache(customerInfo: mockCustomerInfo, appUserID: appUserID)
expect(self.customerInfoManager.lastSentCustomerInfo) == mockCustomerInfo
expect(self.customerInfoManager.lastSentCustomerInfo) == self.mockCustomerInfo

customerInfoManager.clearCustomerInfoCache(forAppUserID: appUserID)

expect(self.customerInfoManager.lastSentCustomerInfo).to(beNil())
expect(self.customerInfoManager.lastSentCustomerInfo) === self.mockCustomerInfo
}

}
Expand Down Expand Up @@ -686,7 +686,7 @@ class CustomerInfoManagerGetCustomerInfoTests: BaseCustomerInfoManagerTests {
func testObserverFetchingCustomerInfoDoesNotDeadlock() throws {
let expectation = XCTestExpectation()

let removeObservation = self.customerInfoManager.monitorChanges { [manager = self.customerInfoManager!] _ in
let removeObservation = self.customerInfoManager.monitorChanges { [manager = self.customerInfoManager!] _, _ in
// Re-fetch customer info when it changes.
// This isn't necessary since it's passed as part of the change,
// but it should not deadlock.
Expand Down
20 changes: 10 additions & 10 deletions Tests/UnitTests/Mocks/MockCustomerInfoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,16 @@ class MockCustomerInfoManager: CustomerInfoManager {
self.invokedFetchAndCacheCustomerInfoIfStaleParametersList.append((appUserID, isAppBackgrounded, completion))
}

var invokedSendCachedCustomerInfoIfAvailable = false
var invokedSendCachedCustomerInfoIfAvailableCount = 0
var invokedSendCachedCustomerInfoIfAvailableParameters: (appUserID: String, Void)?
var invokedSendCachedCustomerInfoIfAvailableParametersList = [(appUserID: String, Void)]()

override func sendCachedCustomerInfoIfAvailable(appUserID: String) {
self.invokedSendCachedCustomerInfoIfAvailable = true
self.invokedSendCachedCustomerInfoIfAvailableCount += 1
self.invokedSendCachedCustomerInfoIfAvailableParameters = (appUserID, ())
self.invokedSendCachedCustomerInfoIfAvailableParametersList.append((appUserID, ()))
var invokedSetLastSentCustomerInfo = false
var invokedSetLastSentCustomerInfoCount = 0
var invokedSetLastSentCustomerInfoParameters: (info: CustomerInfo, Void)?
var invokedSetLastSentCustomerInfoParametersList = [(info: CustomerInfo, Void)]()

override func setLastSentCustomerInfo(_ info: CustomerInfo) {
self.invokedSetLastSentCustomerInfo = true
self.invokedSetLastSentCustomerInfoCount += 1
self.invokedSetLastSentCustomerInfoParameters = (info, ())
self.invokedSetLastSentCustomerInfoParametersList.append((info, ()))
}

var invokedCustomerInfo = false
Expand Down
2 changes: 1 addition & 1 deletion Tests/UnitTests/Mocks/MockPurchasesDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class MockPurchasesDelegate: NSObject, PurchasesDelegate {
var customerInfoReceivedCount = 0

public func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) {
customerInfoReceivedCount += 1
self.customerInfoReceivedCount += 1
self.customerInfo = customerInfo
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,20 @@ class PurchasesConfiguringTests: BasePurchasesTests {
expect(self.purchasesDelegate.customerInfoReceivedCount).toEventually(equal(1))
}

func testFirstInitializationDoesNotClearIntroEligibilityCache() {
self.setupPurchases()
expect(self.purchasesDelegate.customerInfoReceivedCount).toEventually(equal(1))

expect(self.cachingTrialOrIntroPriceEligibilityChecker.invokedClearCache) == false
}

func testFirstInitializationDoesNotClearPurchasedProductsCache() {
self.setupPurchases()
expect(self.purchasesDelegate.customerInfoReceivedCount).toEventually(equal(1))

expect(self.mockPurchasedProductsFetcher.invokedClearCache) == false
}

func testFirstInitializationFromForegroundDelegateForAnonIfNothingCached() {
self.systemInfo.stubbedIsApplicationBackgrounded = false
self.setupPurchases()
Expand All @@ -242,7 +256,7 @@ class PurchasesConfiguringTests: BasePurchasesTests {
let info = try CustomerInfo(data: Self.emptyCustomerInfoData)
let object = try info.jsonEncodedData

self.deviceCache.cachedCustomerInfo[identityManager.currentAppUserID] = object
self.deviceCache.cachedCustomerInfo[self.identityManager.currentAppUserID] = object

self.setupPurchases()

Expand Down
Loading

0 comments on commit cdcd1a0

Please sign in to comment.