From ad3cfc29029b6e647a58bd8181b124253104680c Mon Sep 17 00:00:00 2001 From: Jacob Rakidzich Date: Thu, 9 Oct 2025 22:47:52 -0500 Subject: [PATCH 1/5] move device cache to file cache for potentially large data sets --- Sources/Caching/DeviceCache.swift | 92 +++++++--------- .../SynchronizedLargeItemCache.swift | 103 ++++++++++++++++++ 2 files changed, 145 insertions(+), 50 deletions(-) create mode 100644 Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift diff --git a/Sources/Caching/DeviceCache.swift b/Sources/Caching/DeviceCache.swift index 3e8b8187fe..3a6a355858 100644 --- a/Sources/Caching/DeviceCache.swift +++ b/Sources/Caching/DeviceCache.swift @@ -16,6 +16,7 @@ import Foundation // swiftlint:disable file_length type_body_length class DeviceCache { + private static let defaultBasePath = "RevenueCat" var cachedAppUserID: String? { return self._cachedAppUserID.value } var cachedLegacyAppUserID: String? { return self._cachedLegacyAppUserID.value } @@ -23,6 +24,7 @@ class DeviceCache { private let systemInfo: SystemInfo private let userDefaults: SynchronizedUserDefaults + private let largeItemCache: SynchronizedLargeItemCache private let offeringsCachedObject: InMemoryCachedObject private let _cachedAppUserID: Atomic @@ -31,15 +33,19 @@ class DeviceCache { private var userDefaultsObserver: NSObjectProtocol? private var offeringsCachePreferredLocales: [String] = [] + private let cacheURL: URL? init(systemInfo: SystemInfo, userDefaults: UserDefaults, + fileManager: LargeItemCacheType = FileManager.default, offeringsCachedObject: InMemoryCachedObject = .init()) { self.offeringsCachedObject = offeringsCachedObject self.systemInfo = systemInfo self.userDefaults = .init(userDefaults: userDefaults) + self.fileManager = fileManager self._cachedAppUserID = .init(userDefaults.string(forKey: CacheKeys.appUserDefaults)) self._cachedLegacyAppUserID = .init(userDefaults.string(forKey: CacheKeys.legacyGeneratedAppUserDefaults)) + self.cacheURL = fileManager.createCacheDirectoryIfNeeded(basePath: Self.defaultBasePath) Logger.verbose(Strings.purchase.device_cache_init(self)) } @@ -55,17 +61,26 @@ class DeviceCache { default defaultValue: Value, updater: @Sendable (inout Value) -> Void ) { - self.userDefaults.write { - var value: Value = $0.value(forKey: key) ?? defaultValue + self.largeItemCache.write { cache, cacheURL in + guard let cacheURL = cacheURL else { return } + let fileURL = cacheURL.appendingPathComponent(key.rawValue) + + var value: Value = defaultValue + if let data = try? cache.loadFile(at: fileURL), + let decoded: Value = try? JSONDecoder.default.decode(jsonData: data, logErrors: true) { + value = decoded + } + updater(&value) - $0.set(codable: value, forKey: key) + + if let data = try? JSONEncoder.default.encode(value: value, logErrors: true) { + try? cache.saveData(data, to: fileURL) + } } } func value(for key: Key) -> Value? { - self.userDefaults.read { - $0.value(forKey: key) - } + self.largeItemCache.value(forKey: key) } // MARK: - appUserID @@ -106,6 +121,9 @@ class DeviceCache { self._cachedAppUserID.value = newUserID self._cachedLegacyAppUserID.value = nil } + + // Clear offerings cache from large item cache + self.largeItemCache.removeObject(forKey: CacheKey.offerings(oldAppUserID)) } // MARK: - CustomerInfo @@ -161,8 +179,14 @@ class DeviceCache { // MARK: - Offerings func cachedOfferingsResponseData(appUserID: String) -> Data? { - return self.userDefaults.read { - $0.data(forKey: CacheKey.offerings(appUserID)) + guard let cacheURL = self.cacheURL else { + return nil + } + + let fileURL = cacheURL.appendingPathComponent(CacheKey.offerings(appUserID).rawValue) + + return self.largeItemCache.read { cache, _ in + try? cache.loadFile(at: fileURL) } } @@ -172,9 +196,7 @@ class DeviceCache { // For the cache we need the preferred locales that were used in the request. self.cacheInMemory(offerings: offerings) self.offeringsCachePreferredLocales = preferredLocales - self.userDefaults.write { - $0.set(codable: offerings.response, forKey: CacheKey.offerings(appUserID)) - } + self.largeItemCache.set(codable: offerings.response, forKey: CacheKey.offerings(appUserID)) } func cacheInMemory(offerings: Offerings) { @@ -184,9 +206,7 @@ class DeviceCache { func clearOfferingsCache(appUserID: String) { self.offeringsCachedObject.clearCache() self.offeringsCachePreferredLocales = [] - self.userDefaults.write { - $0.removeObject(forKey: CacheKey.offerings(appUserID)) - } + self.largeItemCache.removeObject(forKey: CacheKey.offerings(appUserID)) } func isOfferingsCacheStale(isAppBackgrounded: Bool) -> Bool { @@ -353,13 +373,18 @@ class DeviceCache { } func store(productEntitlementMapping: ProductEntitlementMapping) { - self.userDefaults.write { - Self.store($0, productEntitlementMapping: productEntitlementMapping) + if self.largeItemCache.set( + codable: productEntitlementMapping, + forKey: CacheKeys.productEntitlementMapping + ) { + self.userDefaults.write { + $0.set(Date(), forKey: CacheKeys.productEntitlementMappingLastUpdated) + } } } var cachedProductEntitlementMapping: ProductEntitlementMapping? { - return self.userDefaults.read(Self.productEntitlementMapping) + return self.largeItemCache.value(forKey: CacheKeys.productEntitlementMapping) } // MARK: - StoreKit 2 @@ -678,20 +703,6 @@ private extension DeviceCache { return userDefaults.date(forKey: CacheKeys.productEntitlementMappingLastUpdated) } - static func productEntitlementMapping(_ userDefaults: UserDefaults) -> ProductEntitlementMapping? { - return userDefaults.value(forKey: CacheKeys.productEntitlementMapping) - } - - static func store( - _ userDefaults: UserDefaults, - productEntitlementMapping mapping: ProductEntitlementMapping - ) { - if userDefaults.set(codable: mapping, - forKey: CacheKeys.productEntitlementMapping) { - userDefaults.set(Date(), forKey: CacheKeys.productEntitlementMappingLastUpdated) - } - } - static func virtualCurrenciesLastUpdated( _ userDefaults: UserDefaults, appUserID: String @@ -724,25 +735,6 @@ private extension DeviceCache { fileprivate extension UserDefaults { - /// - Returns: whether the value could be saved - @discardableResult - func set(codable: T, forKey key: DeviceCacheKeyType) -> Bool { - guard let data = try? JSONEncoder.default.encode(value: codable, logErrors: true) else { - return false - } - - self.set(data, forKey: key) - return true - } - - func value(forKey key: DeviceCacheKeyType) -> T? { - guard let data = self.data(forKey: key) else { - return nil - } - - return try? JSONDecoder.default.decode(jsonData: data, logErrors: true) - } - func set(_ value: Any?, forKey key: DeviceCacheKeyType) { self.set(value, forKey: key.rawValue) } diff --git a/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift b/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift new file mode 100644 index 0000000000..b12df9fe03 --- /dev/null +++ b/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift @@ -0,0 +1,103 @@ +// +// 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 +// +// SynchronizedLargeItemCache.swift +// +// Created by Jacob Zivan Rakidzich on 10/9/25. + +import Foundation + +/// A thread-safe wrapper around `LargeItemCacheType` for synchronized file-based caching operations. +internal final class SynchronizedLargeItemCache { + + private let cache: LargeItemCacheType + private let lock: Lock + private let cacheURL: URL? + + init(cache: LargeItemCacheType, basePath: String) { + self.cache = cache + self.lock = Lock(.nonRecursive) + self.cacheURL = cache.createCacheDirectoryIfNeeded(basePath: basePath) + } + + /// Performs a synchronized read operation + func read(_ action: (LargeItemCacheType, URL?) throws -> T) rethrows -> T { + return try self.lock.perform { + return try action(self.cache, self.cacheURL) + } + } + + /// Performs a synchronized write operation + func write(_ action: (LargeItemCacheType, URL?) throws -> Void) rethrows { + return try self.lock.perform { + try action(self.cache, self.cacheURL) + } + } + + /// Save a codable value to the cache + @discardableResult + func set(codable value: T, forKey key: DeviceCacheKeyType) -> Bool { + guard let cacheURL = self.cacheURL else { + Logger.error("Cache URL is not available") + return false + } + + guard let data = try? JSONEncoder.default.encode(value: value, logErrors: true) else { + return false + } + + let fileURL = cacheURL.appendingPathComponent(key.rawValue) + + do { + try self.write { cache, _ in + try cache.saveData(data, to: fileURL) + } + return true + } catch { + Logger.error("Failed to save codable to cache: \(error)") + return false + } + } + + /// Load a codable value from the cache + func value(forKey key: DeviceCacheKeyType) -> T? { + guard let cacheURL = self.cacheURL else { + return nil + } + + let fileURL = cacheURL.appendingPathComponent(key.rawValue) + + return self.read { cache, _ in + guard let data = try? cache.loadFile(at: fileURL) else { + return nil + } + + return try? JSONDecoder.default.decode(jsonData: data, logErrors: true) + } + } + + /// Remove a cached item + func removeObject(forKey key: DeviceCacheKeyType) { + guard let cacheURL = self.cacheURL else { + return + } + + let fileURL = cacheURL.appendingPathComponent(key.rawValue) + + self.write { _, _ in + try? FileManager.default.removeItem(at: fileURL) + } + } + +} + +// @unchecked because: +// - The cache property is of type LargeItemCacheType which doesn't conform to Sendable +// - However, all access to the cache is synchronized through the Lock, ensuring thread-safety +extension SynchronizedLargeItemCache: @unchecked Sendable {} From e7d14ff2038e82e69fa22db8a0ddc91068c4837e Mon Sep 17 00:00:00 2001 From: Jacob Rakidzich Date: Thu, 16 Oct 2025 12:19:23 -0500 Subject: [PATCH 2/5] Fix vibe coded implementation and test it --- RevenueCat.xcodeproj/project.pbxproj | 8 ++ Sources/Caching/DeviceCache.swift | 2 +- Sources/Caching/LargeItemCacheType.swift | 7 ++ .../SynchronizedLargeItemCache.swift | 2 +- .../SynchronizedLargeItemCacheTests.swift | 115 ++++++++++++++++++ Tests/UnitTests/Mocks/MockSimpleCache.swift | 26 +++- 6 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 Tests/UnitTests/Caching/SynchronizedLargeItemCacheTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 35e100deaf..ca699403ef 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164681CC2E6B577600854AA5 /* VideoPlayerViewUIView.swift */; }; 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164681C92E6B577600854AA5 /* VideoComponentViewModel.swift */; }; 164933242E5808BE00CE43A9 /* PaywallTransitionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164933232E5808BE00CE43A9 /* PaywallTransitionTest.swift */; }; + 167D67092EA151C400B4503F /* SynchronizedLargeItemCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 167D67082EA151C400B4503F /* SynchronizedLargeItemCache.swift */; }; 16A7F4C12E563B89001F9FC8 /* PaywallTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7F4BF2E563B89001F9FC8 /* PaywallTransition.swift */; }; 16A7F4C32E563B91001F9FC8 /* PaywallAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7F4C22E563B91001F9FC8 /* PaywallAnimation.swift */; }; 16A7F4C62E564B97001F9FC8 /* TransitionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7F4C52E564B97001F9FC8 /* TransitionModifier.swift */; }; @@ -84,6 +85,7 @@ 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; + 16E2B7F12EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E2B7F02EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift */; }; 1DB9B2A72E57373900252D58 /* OfferingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB9B2A62E57373600252D58 /* OfferingTests.swift */; }; 1E2F911B2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2F911A2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift */; }; 1E2F91722CCFA98C00BDB016 /* WebRedemptionStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2F91712CCFA98C00BDB016 /* WebRedemptionStrings.swift */; }; @@ -1548,6 +1550,7 @@ 164681CB2E6B577600854AA5 /* VideoPlayerViewNSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewNSView.swift; sourceTree = ""; }; 164681CC2E6B577600854AA5 /* VideoPlayerViewUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewUIView.swift; sourceTree = ""; }; 164933232E5808BE00CE43A9 /* PaywallTransitionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallTransitionTest.swift; sourceTree = ""; }; + 167D67082EA151C400B4503F /* SynchronizedLargeItemCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedLargeItemCache.swift; sourceTree = ""; }; 16A7F4BF2E563B89001F9FC8 /* PaywallTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallTransition.swift; sourceTree = ""; }; 16A7F4C22E563B91001F9FC8 /* PaywallAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallAnimation.swift; sourceTree = ""; }; 16A7F4C52E564B97001F9FC8 /* TransitionModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionModifier.swift; sourceTree = ""; }; @@ -1569,6 +1572,7 @@ 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = ""; }; + 16E2B7F02EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedLargeItemCacheTests.swift; sourceTree = ""; }; 1DB9B2A62E57373600252D58 /* OfferingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferingTests.swift; sourceTree = ""; }; 1E2F911A2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSupportUtilitiesTests.swift; sourceTree = ""; }; 1E2F91712CCFA98C00BDB016 /* WebRedemptionStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRedemptionStrings.swift; sourceTree = ""; }; @@ -4480,6 +4484,7 @@ 37E35AE0CDC4C2AA8260FB58 /* Caching */ = { isa = PBXGroup; children = ( + 16E2B7F02EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift */, 16BCA36F2E4D9BA700B39E7F /* FileRepositoryTests.swift */, 16BCA36D2E4D9B9300B39E7F /* DeferredValueStoresTests.swift */, 37E35D87B7E6F91E27E98F42 /* DeviceCacheTests.swift */, @@ -5080,6 +5085,7 @@ 57F3C0CB29B7A0B10004FD7E /* Concurrency */ = { isa = PBXGroup; children = ( + 167D67082EA151C400B4503F /* SynchronizedLargeItemCache.swift */, 57A0FBEF2749C0C2009E2FC3 /* Atomic.swift */, 57EAE526274324C60060EB74 /* Lock.swift */, 2DDA3E4624DB0B5400EDFE5B /* OperationDispatcher.swift */, @@ -6760,6 +6766,7 @@ 2D00A41D2767C08300FC3DD8 /* ManageSubscriptionsStrings.swift in Sources */, 2DD9F4BE274EADC20031AE2C /* Purchases+async.swift in Sources */, 03C730F32D35F14600297FEC /* PaywallV2CacheWarming.swift in Sources */, + 167D67092EA151C400B4503F /* SynchronizedLargeItemCache.swift in Sources */, B3C4AAD526B8911300E1B3C8 /* Backend.swift in Sources */, 35AAEB452BBB14D000A12548 /* DiagnosticsFileHandler.swift in Sources */, 537B4B302DA9742500CEFF4C /* HealthReportOperation.swift in Sources */, @@ -7347,6 +7354,7 @@ B3F8418F26F3A93400E560FB /* ErrorCodeTests.swift in Sources */, 75D9DE082D79FC0E0068554F /* DiagnosticsEventEncodingTests.swift in Sources */, 5793397228E77A6E00C1232C /* MockPaymentQueue.swift in Sources */, + 16E2B7F12EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift in Sources */, 4FFCED832AA941B200118EF4 /* PaywallEventsBackendTests.swift in Sources */, 5796A38A27D6B96300653165 /* BackendGetCustomerInfoTests.swift in Sources */, ); diff --git a/Sources/Caching/DeviceCache.swift b/Sources/Caching/DeviceCache.swift index 3a6a355858..85a6cf3511 100644 --- a/Sources/Caching/DeviceCache.swift +++ b/Sources/Caching/DeviceCache.swift @@ -42,10 +42,10 @@ class DeviceCache { self.offeringsCachedObject = offeringsCachedObject self.systemInfo = systemInfo self.userDefaults = .init(userDefaults: userDefaults) - self.fileManager = fileManager self._cachedAppUserID = .init(userDefaults.string(forKey: CacheKeys.appUserDefaults)) self._cachedLegacyAppUserID = .init(userDefaults.string(forKey: CacheKeys.legacyGeneratedAppUserDefaults)) self.cacheURL = fileManager.createCacheDirectoryIfNeeded(basePath: Self.defaultBasePath) + self.largeItemCache = .init(cache: fileManager, basePath: "RevenueCat") Logger.verbose(Strings.purchase.device_cache_init(self)) } diff --git a/Sources/Caching/LargeItemCacheType.swift b/Sources/Caching/LargeItemCacheType.swift index 10a99b3d94..b8270e5908 100644 --- a/Sources/Caching/LargeItemCacheType.swift +++ b/Sources/Caching/LargeItemCacheType.swift @@ -25,6 +25,9 @@ protocol LargeItemCacheType { /// Load data from url func loadFile(at url: URL) throws -> Data + /// delete data at url + func remove(_ url: URL) throws + /// Creates a directory in the cache from a base path func createCacheDirectoryIfNeeded(basePath: String) -> URL? } @@ -70,4 +73,8 @@ extension FileManager: LargeItemCacheType { func loadFile(at url: URL) throws -> Data { return try Data(contentsOf: url) } + + func remove(_ url: URL) throws { + try self.removeItem(at: url) + } } diff --git a/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift b/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift index b12df9fe03..05b2adb634 100644 --- a/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift +++ b/Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift @@ -91,7 +91,7 @@ internal final class SynchronizedLargeItemCache { let fileURL = cacheURL.appendingPathComponent(key.rawValue) self.write { _, _ in - try? FileManager.default.removeItem(at: fileURL) + try? self.cache.remove(fileURL) } } diff --git a/Tests/UnitTests/Caching/SynchronizedLargeItemCacheTests.swift b/Tests/UnitTests/Caching/SynchronizedLargeItemCacheTests.swift new file mode 100644 index 0000000000..0a231b9604 --- /dev/null +++ b/Tests/UnitTests/Caching/SynchronizedLargeItemCacheTests.swift @@ -0,0 +1,115 @@ +// +// 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 +// +// SynchronizedLargeItemCacheTests.swift +// +// Created by Jacob Zivan Rakidzich on 10/10/25. + +@testable import RevenueCat +import XCTest + +@MainActor +class SynchronizedLargeItemCacheTests: TestCase { + let baseDirectory = URL(string: "data:mock-dir").unsafelyUnwrapped + + func testSetPersistsDataToCacheDirectory() throws { + let (mock, sut) = self.makeSystemUnderTest() + let key = TestCacheKey(rawValue: "test-key") + let value = TestValue(identifier: "abc", count: 42) + + mock + .stubSaveData( + with: .success( + .init( + data: value.asData, + url: baseDirectory.appendingPathExtension(key.rawValue) + ) + ) + ) + + let didStore = sut.set(codable: value, forKey: key) + + XCTAssertTrue(didStore) + XCTAssertEqual(mock.saveDataInvocations.count, 1) + } + + func testValueReturnsDecodedData() throws { + let (mock, sut) = self.makeSystemUnderTest() + let key = TestCacheKey(rawValue: "value-key") + let value = TestValue(identifier: "value", count: 7) + + mock.stubLoadFile(with: .success(value.asData)) + + let cached: TestValue? = sut.value(forKey: key) + + XCTAssertEqual(cached, value) + } + + func testValueReturnsNilWhenErrorIsReturned() { + let (mock, sut) = self.makeSystemUnderTest() + let key = TestCacheKey(rawValue: "missing-key") + + mock.stubLoadFile(with: .failure(MockError())) + + let cached: TestValue? = sut.value(forKey: key) + + XCTAssertNil(cached) + } + + func testRemoveObjectDeletesStoredFile() throws { + let (mock, sut) = self.makeSystemUnderTest() + let key = TestCacheKey(rawValue: "remove-key") + + sut.removeObject(forKey: key) + + XCTAssertEqual(mock.removeInvocations.count, 1) + } + + // MARK: - Helpers + + private func makeSystemUnderTest( + file: StaticString = #filePath, + line: UInt = #line + ) -> (MockSimpleCache, SynchronizedLargeItemCache) { + + let cache = createAndTrackForMemoryLeak( + file: file, + line: line, + MockSimpleCache(cacheDirectory: baseDirectory) + ) + + let basePath = "SynchronizedLargeItemCacheTests-\(UUID().uuidString)" + let sut = createAndTrackForMemoryLeak( + file: file, + line: line, + SynchronizedLargeItemCache(cache: cache, basePath: basePath) + ) + + return (cache, sut) + } + +} + +// MARK: - Test helpers + +private struct TestCacheKey: DeviceCacheKeyType { + let rawValue: String +} + +private struct TestValue: Codable, Equatable { + var identifier: String + var count: Int + + var asData: Data { + // swiftlint:disable:next force_try + try! JSONEncoder.default.encode(self) + } +} + +private struct MockError: Error { } diff --git a/Tests/UnitTests/Mocks/MockSimpleCache.swift b/Tests/UnitTests/Mocks/MockSimpleCache.swift index fe25563865..588ef6b47f 100644 --- a/Tests/UnitTests/Mocks/MockSimpleCache.swift +++ b/Tests/UnitTests/Mocks/MockSimpleCache.swift @@ -21,6 +21,11 @@ class MockSimpleCache: LargeItemCacheType, @unchecked Sendable { var saveDataInvocations: [SaveData] = [] var saveDataResponses: [Result] = [] + var loadFileInvocations = [URL]() + var loadFileResponses = [Result]() + + var removeInvocations = [URL]() + var cachedContentExistsInvocations: [URL] = [] var cachedContentExistsResponses: [Bool] = [] @@ -62,8 +67,25 @@ class MockSimpleCache: LargeItemCacheType, @unchecked Sendable { } func loadFile(at url: URL) throws -> Data { - assert(false, "to do: implement later when used") - return Data() + try lock.withLock { + loadFileInvocations.append(url) + switch loadFileResponses[loadFileInvocations.count - 1] { + case .success(let data): + return data + case .failure(let error): + throw error + } + } + } + + func stubLoadFile(at index: Int = 0, with result: Result) { + lock.withLock { + loadFileResponses.insert(result, at: index) + } + } + + func remove(_ url: URL) throws { + removeInvocations.append(url) } func createCacheDirectoryIfNeeded(basePath: String) -> URL? { From b96d8f02948f6bb1518effeb5d06c8acde9befd9 Mon Sep 17 00:00:00 2001 From: Jacob Rakidzich Date: Thu, 16 Oct 2025 12:40:35 -0500 Subject: [PATCH 3/5] Update device cache tests --- .../UnitTests/Caching/DeviceCacheTests.swift | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/Tests/UnitTests/Caching/DeviceCacheTests.swift b/Tests/UnitTests/Caching/DeviceCacheTests.swift index b24680f3e5..4b3a0ae061 100644 --- a/Tests/UnitTests/Caching/DeviceCacheTests.swift +++ b/Tests/UnitTests/Caching/DeviceCacheTests.swift @@ -15,6 +15,7 @@ class DeviceCacheTests: TestCase { private var systemInfo: MockSystemInfo! = nil private var preferredLocalesProvider: PreferredLocalesProvider! = nil private var mockUserDefaults: MockUserDefaults! = nil + private var mockFileCache: MockSimpleCache! = nil private var deviceCache: DeviceCache! = nil private var mockVirtualCurrenciesData: Data! @@ -24,6 +25,7 @@ class DeviceCacheTests: TestCase { preferredLocalesProvider: self.preferredLocalesProvider) self.systemInfo.stubbedIsSandbox = false self.mockUserDefaults = MockUserDefaults() + self.mockFileCache = MockSimpleCache() let mockVirtualCurrencies = VirtualCurrencies(virtualCurrencies: [ "USD": VirtualCurrency(balance: 100, name: "US Dollar", code: "USD", serverDescription: "dollar"), @@ -78,6 +80,7 @@ class DeviceCacheTests: TestCase { func testClearCachesForAppUserIDAndSaveNewUserIDRemovesCachedOfferings() { let offerings: Offerings = .empty + self.mockFileCache.stubSaveData(with: .success(.init(data: .init(), url: .mockFileLocation))) self.deviceCache.cache(offerings: offerings, preferredLocales: ["en-US"], appUserID: "cesar") expect(self.deviceCache.cachedOfferings) == offerings @@ -225,15 +228,17 @@ class DeviceCacheTests: TestCase { func testOfferingsAreProperlyCached() throws { let expectedOfferings = try Self.createSampleOfferings() + mockFileCache + .stubSaveData( + with: .success(.init(data: try expectedOfferings.response.jsonEncodedData, url: .mockFileLocation)) + ) + + expect(self.mockFileCache.saveDataInvocations.count == 0) + self.deviceCache.cache(offerings: expectedOfferings, preferredLocales: ["en-US"], appUserID: "user") expect(self.deviceCache.cachedOfferings) === expectedOfferings - - let storedData = try XCTUnwrap( - self.mockUserDefaults.mockValues["com.revenuecat.userdefaults.offerings.user"] as? Data - ) - let offerings = try JSONDecoder.default.decode(OfferingsResponse.self, from: storedData) - expect(offerings) == expectedOfferings.response + expect(self.mockFileCache.saveDataInvocations.count == 1) } func testCacheOfferingsInMemory() throws { @@ -367,6 +372,8 @@ class DeviceCacheTests: TestCase { func testIsOfferingsCacheStaleIfPreferredLocalesChange() throws { let sampleOfferings = try Self.createSampleOfferings() + self.mockFileCache.stubSaveData(with: .success(.init(data: .init(), url: .mockFileLocation))) + self.deviceCache.cache(offerings: sampleOfferings, preferredLocales: ["en-US"], appUserID: "user") expect(self.deviceCache.isOfferingsCacheStale(isAppBackgrounded: false)) == false @@ -378,6 +385,7 @@ class DeviceCacheTests: TestCase { expect(self.deviceCache.isOfferingsCacheStale(isAppBackgrounded: false)) == true expect(self.deviceCache.isOfferingsCacheStale(isAppBackgrounded: true)) == true + self.mockFileCache.stubSaveData(at: 1, with: .success(.init(data: .init(), url: .mockFileLocation))) self.deviceCache.cache(offerings: .empty, preferredLocales: ["es-ES", "en-US"], appUserID: "user") expect(self.deviceCache.isOfferingsCacheStale(isAppBackgrounded: false)) == false @@ -399,9 +407,7 @@ class DeviceCacheTests: TestCase { self.deviceCache.clearOfferingsCache(appUserID: "user") expect(mockCachedObject.invokedClearCache) == true - expect(self.mockUserDefaults.removeObjectForKeyCalledValues).to(contain([ - "com.revenuecat.userdefaults.offerings.user" - ])) + expect(self.mockFileCache.removeInvocations.count == 1) } func testSetLatestAdvertisingIdsByNetworkSentMapsAttributionNetworksToStringKeys() { @@ -468,20 +474,22 @@ class DeviceCacheTests: TestCase { expect(storedAttributes as? [String: [String: [String: NSObject]]]) == expectedAttributesSet } - func testCacheEmptyProductEntitlementMapping() { + func testCacheEmptyProductEntitlementMapping() throws { let data = ProductEntitlementMapping(entitlementsByProduct: [:]) - + self.mockFileCache.stubSaveData(with: .success(.init(data: .init(), url: .mockFileLocation))) + self.mockFileCache.stubLoadFile(with: .success(try data.jsonEncodedData)) self.deviceCache.store(productEntitlementMapping: data) expect(self.deviceCache.cachedProductEntitlementMapping) == data } - func testCacheProductEntitlementMapping() { + func testCacheProductEntitlementMapping() throws { let data = ProductEntitlementMapping(entitlementsByProduct: [ "1": ["pro_1"], "2": ["pro_2"], "3": ["pro_1", "pro_2"] ]) - + self.mockFileCache.stubSaveData(with: .success(.init(data: .init(), url: .mockFileLocation))) + self.mockFileCache.stubLoadFile(with: .success(try data.jsonEncodedData)) self.deviceCache.store(productEntitlementMapping: data) expect(self.deviceCache.cachedProductEntitlementMapping) == data } @@ -491,6 +499,7 @@ class DeviceCacheTests: TestCase { } func testCacheProductEntitlementMappingUpdatesLastUpdatedDate() throws { + self.mockFileCache.stubSaveData(with: .success(.init(data: Data(), url: .mockFileLocation))) self.deviceCache.store(productEntitlementMapping: .init(entitlementsByProduct: [:])) let key = DeviceCache.CacheKeys.productEntitlementMappingLastUpdated.rawValue @@ -805,8 +814,11 @@ class DeviceCacheTests: TestCase { private extension DeviceCacheTests { func create() -> DeviceCache { - return DeviceCache(systemInfo: self.systemInfo, - userDefaults: self.mockUserDefaults) + return DeviceCache( + systemInfo: self.systemInfo, + userDefaults: self.mockUserDefaults, + fileManager: self.mockFileCache + ) } static func createSampleOfferings() throws -> Offerings { @@ -876,3 +888,7 @@ private extension Offerings { ) } + +private extension URL { + static let mockFileLocation: URL = URL(string: "data:mock-file-location").unsafelyUnwrapped +} From 49b8fc9c999b00577b6166fc3d6b027e932a33d0 Mon Sep 17 00:00:00 2001 From: Jacob Rakidzich Date: Thu, 16 Oct 2025 20:56:48 -0500 Subject: [PATCH 4/5] bring back some of the old functionality --- Sources/Caching/LargeItemCacheType.swift | 11 +++++++++-- Tests/UnitTests/Mocks/MockSimpleCache.swift | 12 +++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/Caching/LargeItemCacheType.swift b/Sources/Caching/LargeItemCacheType.swift index e095bb28cf..819311d6e8 100644 --- a/Sources/Caching/LargeItemCacheType.swift +++ b/Sources/Caching/LargeItemCacheType.swift @@ -14,10 +14,12 @@ import Foundation /// An inteface representing a simple cache -@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) protocol LargeItemCacheType { + /// Store data to a url + func saveData(_ data: Data, to url: URL) throws /// Store data to a url + @available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) func saveData(_ bytes: AsyncThrowingStream, to url: URL, checksum: Checksum?) async throws /// Check if there is content cached at the url @@ -33,14 +35,19 @@ protocol LargeItemCacheType { func createCacheDirectoryIfNeeded(basePath: String) -> URL? } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) extension FileManager: LargeItemCacheType { /// A URL for a cache directory if one is present private var cacheDirectory: URL? { return urls(for: .cachesDirectory, in: .userDomainMask).first } + /// Store data to a url + func saveData(_ data: Data, to url: URL) throws { + try data.write(to: url) + } + /// Store data to a url and validate that the file is correct before saving + @available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) func saveData( _ bytes: AsyncThrowingStream, to url: URL, diff --git a/Tests/UnitTests/Mocks/MockSimpleCache.swift b/Tests/UnitTests/Mocks/MockSimpleCache.swift index 10bd52694a..8ae7ece71e 100644 --- a/Tests/UnitTests/Mocks/MockSimpleCache.swift +++ b/Tests/UnitTests/Mocks/MockSimpleCache.swift @@ -14,7 +14,6 @@ import Foundation @testable import RevenueCat -@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) class MockSimpleCache: LargeItemCacheType, @unchecked Sendable { var cacheDirectory: URL? @@ -35,6 +34,17 @@ class MockSimpleCache: LargeItemCacheType, @unchecked Sendable { self.cacheDirectory = cacheDirectory } + func saveData(_ data: Data, to url: URL) throws { + saveDataInvocations.append(.init(data: data, url: url)) + switch saveDataResponses[saveDataInvocations.count - 1] { + case .failure(let error): + throw error + default: + break + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *) func saveData( _ bytes: AsyncThrowingStream, to url: URL, From 26cbf77dbd7e0a10923b3ebeb8119311ac072781 Mon Sep 17 00:00:00 2001 From: Jacob Rakidzich Date: Thu, 16 Oct 2025 21:05:02 -0500 Subject: [PATCH 5/5] revert accidental deletion --- .../xcshareddata/swiftpm/Package.resolved | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 RevenueCat.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/RevenueCat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RevenueCat.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..5372d24237 --- /dev/null +++ b/RevenueCat.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,85 @@ +{ + "pins" : [ + { + "identity" : "accessibilitysnapshot", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EmergeTools/AccessibilitySnapshot.git", + "state" : { + "revision" : "54046d8fa44b9fa9f0e2229526f6ed89cb5e0ec2", + "version" : "1.0.2" + } + }, + { + "identity" : "cwlcatchexception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "state" : { + "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", + "version" : "2.2.1" + } + }, + { + "identity" : "cwlpreconditiontesting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state" : { + "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", + "version" : "2.2.2" + } + }, + { + "identity" : "flyingfox", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/FlyingFox.git", + "state" : { + "revision" : "f7829d4aca8cbfecb410a1cce872b1b045224aa1", + "version" : "0.16.0" + } + }, + { + "identity" : "nimble", + "kind" : "remoteSourceControl", + "location" : "https://github.com/quick/nimble", + "state" : { + "revision" : "7795df4fff1a9cd231fe4867ae54f4dc5f5734f9", + "version" : "13.7.1" + } + }, + { + "identity" : "ohhttpstubs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AliSoftware/OHHTTPStubs.git", + "state" : { + "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9", + "version" : "9.1.0" + } + }, + { + "identity" : "simpledebugger", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EmergeTools/SimpleDebugger.git", + "state" : { + "revision" : "f065263eb5db95874b690408d136b573c939db9e", + "version" : "1.0.0" + } + }, + { + "identity" : "snapshotpreviews", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EmergeTools/SnapshotPreviews.git", + "state" : { + "revision" : "0d1c6c282a83e5350899046efd3493558dc2d3c8", + "version" : "0.10.24" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "26ed3a2b4a2df47917ca9b790a57f91285b923fb" + } + } + ], + "version" : 2 +}