From f1326ad89f60ec51cb4a752c1e1a90bd8f72af08 Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Wed, 23 Oct 2024 17:47:27 +0600 Subject: [PATCH 01/20] feat: [UXP-3578] Allow changing the default flag cache storage --- LaunchDarkly/LaunchDarkly/LDClient.swift | 2 +- LaunchDarkly/LaunchDarkly/Models/LDConfig.swift | 8 ++++++++ .../ServiceObjects/Cache/KeyedValueCache.swift | 8 ++++---- .../ServiceObjects/ClientServiceFactory.swift | 6 ++++-- .../ServiceObjects/Cache/FeatureFlagCacheSpec.swift | 6 +++--- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 91961269..ada02f80 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -752,7 +752,7 @@ public class LDClient { os_log("%s LDClient starting", log: config.logger, type: .debug, typeName(and: #function)) - let serviceFactory = serviceFactory ?? ClientServiceFactory(logger: config.logger) + let serviceFactory = serviceFactory ?? ClientServiceFactory(logger: config.logger, cacheBuilder: config.cacheBuilder) var keys = [config.mobileKey] keys.append(contentsOf: config.getSecondaryMobileKeys().values) serviceFactory.makeCacheConverter().convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts) diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index 99da1611..0f04cf91 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -253,6 +253,11 @@ public struct LDConfig { /// The default logger for the SDK. Can be overridden to provide customization. static let logger: OSLog = OSLog(subsystem: "com.launchdarkly", category: "ios-client-sdk") + /// The default cache for feature flags is UserDefaults + static let cacheBuilder: (String?) -> KeyedValueCaching = { cacheKey in + UserDefaults(suiteName: cacheKey)! + } + /// The default behavior for event payload compression. static let enableCompression: Bool = false } @@ -427,6 +432,9 @@ public struct LDConfig { /// Configure the logger that will be used by the rest of the SDK. public var logger: OSLog = Defaults.logger + /// Configure the persistent storage for caching flags locally + public var cacheBuilder: (String?) -> KeyedValueCaching = Defaults.cacheBuilder + /// LaunchDarkly defined minima for selected configurable items public let minima: Minima diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift index a88b78c5..e01871ec 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift @@ -1,7 +1,7 @@ import Foundation // sourcery: autoMockable -protocol KeyedValueCaching { +public protocol KeyedValueCaching { func set(_ value: Data, forKey: String) func data(forKey: String) -> Data? func dictionary(forKey: String) -> [String: Any]? @@ -11,15 +11,15 @@ protocol KeyedValueCaching { } extension UserDefaults: KeyedValueCaching { - func set(_ value: Data, forKey: String) { + public func set(_ value: Data, forKey: String) { set(value as Any?, forKey: forKey) } - func removeAll() { + public func removeAll() { dictionaryRepresentation().keys.forEach { removeObject(forKey: $0) } } - func keys() -> [String] { + public func keys() -> [String] { dictionaryRepresentation().keys.map { String($0) } } } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift index 66015289..2bb6fe17 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift @@ -28,13 +28,15 @@ protocol ClientServiceCreating { final class ClientServiceFactory: ClientServiceCreating { private let logger: OSLog + private let cacheBuilder: (String?) -> KeyedValueCaching - init(logger: OSLog) { + init(logger: OSLog, cacheBuilder: @escaping ((String?) -> KeyedValueCaching)) { self.logger = logger + self.cacheBuilder = cacheBuilder } func makeKeyedValueCache(cacheKey: String?) -> KeyedValueCaching { - UserDefaults(suiteName: cacheKey)! + cacheBuilder(cacheKey) } func makeFeatureFlagCache(mobileKey: MobileKey, maxCachedContexts: Int) -> FeatureFlagCaching { diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift index d76417af..965d3f89 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift @@ -71,7 +71,7 @@ final class FeatureFlagCacheSpec: XCTestCase { func testCanReuseFullCacheIfHashIsSame() { let now = Date() - let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled), mobileKey: "abc", maxCachedContexts: 5) + let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheBuilder: LDConfig.Defaults.cacheBuilder), mobileKey: "abc", maxCachedContexts: 5) flagCache.saveCachedData(testFlagCollection.flags, cacheKey: "key", contextHash: "hash", lastUpdated: now, etag: "example-etag") let results = flagCache.getCachedData(cacheKey: "key", contextHash: "hash") @@ -82,7 +82,7 @@ final class FeatureFlagCacheSpec: XCTestCase { func testCanReusePartialCacheIfOnlyHashChanges() { let now = Date() - let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled), mobileKey: "abc", maxCachedContexts: 5) + let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheBuilder: LDConfig.Defaults.cacheBuilder), mobileKey: "abc", maxCachedContexts: 5) flagCache.saveCachedData(testFlagCollection.flags, cacheKey: "key", contextHash: "hash", lastUpdated: now, etag: "example-etag") let results = flagCache.getCachedData(cacheKey: "key", contextHash: "changed-hash") @@ -93,7 +93,7 @@ final class FeatureFlagCacheSpec: XCTestCase { func testCannotReuseCacheIfKeyChanges() { let now = Date() - let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled), mobileKey: "abc", maxCachedContexts: 5) + let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheBuilder: LDConfig.Defaults.cacheBuilder), mobileKey: "abc", maxCachedContexts: 5) flagCache.saveCachedData(testFlagCollection.flags, cacheKey: "key", contextHash: "hash", lastUpdated: now, etag: "example-etag") let results = flagCache.getCachedData(cacheKey: "changed-key", contextHash: "hash") From 4133d701401c4878a2e6321b31ce3fb077a00895 Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Wed, 23 Oct 2024 19:51:59 +0600 Subject: [PATCH 02/20] feat: [UXP-3578] Add LDInMemoryCache --- .../Cache/LDInMemoryCache.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift new file mode 100644 index 00000000..b88d00e4 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -0,0 +1,57 @@ +import Foundation + +public final class LDInMemoryCache: KeyedValueCaching { + + private static var instances: [String: LDInMemoryCache] = [:] + private static let instancesLock = NSLock() + + private var cache: [String: Any] = [:] + private var cacheLock = NSLock() + + public static var builder: (String) -> KeyedValueCaching { + return { cacheKey in + instancesLock.lock() + defer { instancesLock.unlock() } + if let cache = instances[cacheKey] { return cache } + let cache = LDInMemoryCache() + instances[cacheKey] = cache + return cache + } + } + + public func set(_ value: Data, forKey: String) { + cacheLock.lock() + defer { cacheLock.unlock() } + cache[forKey] = value + } + + public func data(forKey: String) -> Data? { + cacheLock.lock() + defer { cacheLock.unlock() } + return cache[forKey] as? Data + } + + public func dictionary(forKey: String) -> [String : Any]? { + cacheLock.lock() + defer { cacheLock.unlock() } + return cache[forKey] as? [String: Any] + } + + public func removeObject(forKey: String) { + cacheLock.lock() + defer { cacheLock.unlock() } + cache.removeValue(forKey: forKey) + } + + public func removeAll() { + cacheLock.lock() + defer { cacheLock.unlock() } + cache.removeAll() + } + + public func keys() -> [String] { + cacheLock.lock() + defer { cacheLock.unlock() } + return Array(cache.keys) + } +} From 215f655b8d92a85dd7c2c93d31b841d0a93bbf22 Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Wed, 23 Oct 2024 19:52:15 +0600 Subject: [PATCH 03/20] feat: [UXP-3578] Add KeyedValueCaching tests --- .../Cache/KeyedValueCachingSpec.swift | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift new file mode 100644 index 00000000..6b8a1b70 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -0,0 +1,109 @@ +import Foundation +import XCTest + +@testable import LaunchDarkly + +final class UserDefaultsCachingSpec: KeyedValueCachingBaseSpec { + override func makeSut(_ key: String) -> KeyedValueCaching { + return LDConfig.Defaults.cacheBuilder(key) + } +} + +final class LDInMemoryCacheSpec: KeyedValueCachingBaseSpec { + override func makeSut(_ key: String) -> KeyedValueCaching { + return LDInMemoryCache.builder(key) + } +} + +// MARK: - Base spec + +class KeyedValueCachingBaseSpec: XCTestCase { + + func makeSut(_ key: String) -> KeyedValueCaching { + fatalError("Override in a subclass") + } + + private func skipForBaseSpec() throws { + if type(of: self) == KeyedValueCachingBaseSpec.self { + throw XCTSkip() + } + } + + // MARK: - public KeyedValueCaching protocol methods + + func testDataForkey() throws { + try skipForBaseSpec() + let data = Data("random".utf8) + makeSut("test").set(data, forKey: "test_key") + XCTAssertEqual(makeSut("test").data(forKey: "test_key"), data) + } + + func testDictionaryForKey() throws { + try skipForBaseSpec() + // No-op: no public setter declared in the protocol, unused in the library + } + + func testRemoveObjectForKey() throws { + try skipForBaseSpec() + let data = Data("random".utf8) + makeSut("test").set(data, forKey: "test_key") + makeSut("test").removeObject(forKey: "test_key") + XCTAssertNil(makeSut("test").data(forKey: "test_key")) + } + + func testRemoveAll() throws { + try skipForBaseSpec() + let data = Data("random".utf8) + makeSut("test").set(data, forKey: "test_key") + makeSut("test").removeAll() + XCTAssertNil(makeSut("test").data(forKey: "test_key")) + } + + func testKeysGetter() throws { + try skipForBaseSpec() + let sut = makeSut("test") + let keys = Array(0..<10).map { "key_\($0)" } + keys.forEach { sut.set(Data($0.utf8), forKey: $0) } + let storedKeys = makeSut("test").keys() + // storedKeys may contain external key-values + XCTAssertEqual(Set(storedKeys).intersection(Set(keys)), Set(keys)) + } + + // MARK: - Non-trivial access conditions + + func testSeparateCacheInstancePerCacheKey() throws { + try skipForBaseSpec() + let sut1 = makeSut("key_1") + let sut2 = makeSut("key_2") + let sut3 = makeSut("key_3") + sut1.set(Data("1".utf8), forKey: "test_key") + sut2.set(Data("2".utf8), forKey: "test_key") + sut3.set(Data("3".utf8), forKey: "test_key") + sut3.removeAll() + XCTAssertEqual(sut1.data(forKey: "test_key"), Data("1".utf8)) + XCTAssertEqual(sut2.data(forKey: "test_key"), Data("2".utf8)) + XCTAssertNil(sut3.data(forKey: "test_key")) + } + + func testConcurrentAccess() throws { + try skipForBaseSpec() + DispatchQueue.concurrentPerform(iterations: 1000) { index in + let cacheKey = "cache_\(index % 3)" + let sut = makeSut(cacheKey) + if index % 9 == 0 { + sut.removeAll() + } else { + let keyIndex = index % 5 + sut.set(Data("value_\(keyIndex)".utf8), forKey: "\(keyIndex)") + } + } + for cacheIndex in 0..<3 { + let sut = makeSut("cache_\(cacheIndex)") + let keys = sut.keys() + for key in keys { + guard let index = Int(key) else { continue } + XCTAssertEqual(sut.data(forKey: key), Data("value_\(index)".utf8)) + } + } + } +} From 82418bb862e9b3191745a8a78cefd9c8185c6c5c Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Wed, 23 Oct 2024 21:27:42 +0600 Subject: [PATCH 04/20] feat: [UXP-3578] Add foundation for LDFileCache --- .../ServiceObjects/Cache/LDFileCache.swift | 69 +++++++++++++++++++ .../Cache/LDInMemoryCache.swift | 7 +- .../Cache/KeyedValueCachingSpec.swift | 8 ++- 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift new file mode 100644 index 00000000..cf0ef0b1 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -0,0 +1,69 @@ +import Foundation + +public final class LDFileCache: KeyedValueCaching { + + private static let instancesLock = NSLock() + private let cacheKey: String + private let inMemoryCache: LDInMemoryCache + + public static func builder() -> (String) -> KeyedValueCaching { + return { cacheKey in + instancesLock.lock() + defer { instancesLock.unlock() } + let inMemoryCache = LDInMemoryCache.builder()(cacheKey) + let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache) + if inMemoryCache.data(forKey: "is_initialized") == nil { + cache.deserialize() + inMemoryCache.set(Data(), forKey: "is_initialized") + } + return cache + } + } + + public func set(_ value: Data, forKey: String) { + inMemoryCache.set(value, forKey: forKey) + scheduleSerialization() + } + + public func data(forKey: String) -> Data? { + return inMemoryCache.data(forKey: forKey) + } + + public func dictionary(forKey: String) -> [String : Any]? { + // Legacy - not used by the library + return nil + } + + public func removeObject(forKey: String) { + inMemoryCache.removeObject(forKey: forKey) + scheduleSerialization() + } + + public func removeAll() { + inMemoryCache.removeAll() + scheduleSerialization() + } + + public func keys() -> [String] { + return inMemoryCache.keys() + } + + // MARK: - Internal + + init(cacheKey: String, inMemoryCache: LDInMemoryCache) { + self.cacheKey = cacheKey + self.inMemoryCache = inMemoryCache + } + + func deserialize() { + + } + + func serialize() { + + } + + func scheduleSerialization() { + + } +} diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift index b88d00e4..a16df4c2 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -8,7 +8,7 @@ public final class LDInMemoryCache: KeyedValueCaching { private var cache: [String: Any] = [:] private var cacheLock = NSLock() - public static var builder: (String) -> KeyedValueCaching { + public static func builder() -> (String) -> LDInMemoryCache { return { cacheKey in instancesLock.lock() defer { instancesLock.unlock() } @@ -32,9 +32,8 @@ public final class LDInMemoryCache: KeyedValueCaching { } public func dictionary(forKey: String) -> [String : Any]? { - cacheLock.lock() - defer { cacheLock.unlock() } - return cache[forKey] as? [String: Any] + // Legacy - not used by the library + return nil } public func removeObject(forKey: String) { diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift index 6b8a1b70..069c8946 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -11,7 +11,13 @@ final class UserDefaultsCachingSpec: KeyedValueCachingBaseSpec { final class LDInMemoryCacheSpec: KeyedValueCachingBaseSpec { override func makeSut(_ key: String) -> KeyedValueCaching { - return LDInMemoryCache.builder(key) + return LDInMemoryCache.builder()(key) + } +} + +final class LDFileCacheSpec: KeyedValueCachingBaseSpec { + override func makeSut(_ key: String) -> KeyedValueCaching { + return LDFileCache.builder()(key) } } From 4517e05bfd97ad53c04f054ffc97f00b39652664 Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Wed, 23 Oct 2024 23:33:54 +0600 Subject: [PATCH 05/20] feat: [UXP-3578] Add debounce --- .../ServiceObjects/Cache/LDFileCache.swift | 11 +++-- LaunchDarkly/LaunchDarkly/Util.swift | 26 ++++++++++++ LaunchDarkly/LaunchDarklyTests/UtilSpec.swift | 40 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index cf0ef0b1..3b5882f4 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -5,6 +5,7 @@ public final class LDFileCache: KeyedValueCaching { private static let instancesLock = NSLock() private let cacheKey: String private let inMemoryCache: LDInMemoryCache + private let fileQueue = DispatchQueue(label: "ld_file_io", qos: .utility).debouncer() public static func builder() -> (String) -> KeyedValueCaching { return { cacheKey in @@ -13,7 +14,7 @@ public final class LDFileCache: KeyedValueCaching { let inMemoryCache = LDInMemoryCache.builder()(cacheKey) let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache) if inMemoryCache.data(forKey: "is_initialized") == nil { - cache.deserialize() + cache.deserializeFromFile() inMemoryCache.set(Data(), forKey: "is_initialized") } return cache @@ -55,15 +56,17 @@ public final class LDFileCache: KeyedValueCaching { self.inMemoryCache = inMemoryCache } - func deserialize() { + func deserializeFromFile() { } - func serialize() { + func serializeToFile() { } func scheduleSerialization() { - + fileQueue.debounce(interval: .milliseconds(500)) { [weak self] in + self?.serializeToFile() + } } } diff --git a/LaunchDarkly/LaunchDarkly/Util.swift b/LaunchDarkly/LaunchDarkly/Util.swift index aa7deeee..b40c7115 100644 --- a/LaunchDarkly/LaunchDarkly/Util.swift +++ b/LaunchDarkly/LaunchDarkly/Util.swift @@ -1,5 +1,6 @@ import CommonCrypto import Foundation +import Dispatch class Util { internal static let validKindCharacterSet = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") @@ -28,3 +29,28 @@ extension String { return true } } + +extension DispatchQueue { + + func debouncer() -> Debouncer { + Debouncer(queue: self) + } + + final class Debouncer { + private let lock = NSLock() + private let queue: DispatchQueue + private var workItem: DispatchWorkItem? + + fileprivate init(queue: DispatchQueue) { + self.queue = queue + } + + func debounce(interval: DispatchTimeInterval, action: @escaping () -> Void) { + lock.lock(); defer { lock.unlock() } + workItem?.cancel() + let workItem = DispatchWorkItem(block: action) + self.workItem = workItem + queue.asyncAfter(deadline: .now() + interval, execute: workItem) + } + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift b/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift index 6f3c0493..2f56e9d6 100644 --- a/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift @@ -18,4 +18,44 @@ final class UtilSpec: XCTestCase { let output = Util.sha256(input).base64UrlEncodedString XCTAssertEqual(output, expectedOutput) } + + func testDispatchQueueDebounceConcurrentRequests() { + let exp = XCTestExpectation(description: #function) + let queue = DispatchQueue(label: "test") + let sut = queue.debouncer() + var counter: Int = 0 + DispatchQueue.concurrentPerform(iterations: 100) { _ in + sut.debounce(interval: .milliseconds(200)) { + counter += 1 + } + } + XCTAssertEqual(counter, 0) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + XCTAssertEqual(counter, 0) + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + XCTAssertEqual(counter, 1) + exp.fulfill() + } + wait(for: [exp], timeout: 1) + } + + func testDispatchQueueDebounceDelayedRequests() { + let exp = XCTestExpectation(description: #function) + let queue = DispatchQueue(label: "test") + let sut = queue.debouncer() + var counter: Int = 0 + for index in 0..<5 { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(index * 100)) { + sut.debounce(interval: .milliseconds(200)) { + counter += 1 + } + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(800)) { + XCTAssertEqual(counter, 1) + exp.fulfill() + } + wait(for: [exp], timeout: 1) + } } From 503e8a7a111ab4b0de5f75b89ffe54ff80354fad Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 01:13:24 +0600 Subject: [PATCH 06/20] refactor: [UXP-3578] Cache factory name and parameters --- LaunchDarkly/LaunchDarkly/LDClient.swift | 2 +- LaunchDarkly/LaunchDarkly/Models/LDConfig.swift | 4 ++-- .../ServiceObjects/Cache/KeyedValueCache.swift | 16 +++------------- .../ServiceObjects/Cache/LDInMemoryCache.swift | 4 ++-- .../ServiceObjects/Cache/UserDefaultsCache.swift | 15 +++++++++++++++ .../ServiceObjects/ClientServiceFactory.swift | 8 ++++---- .../Cache/FeatureFlagCacheSpec.swift | 6 +++--- .../Cache/KeyedValueCachingSpec.swift | 4 ++-- 8 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/UserDefaultsCache.swift diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index ada02f80..c40e9355 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -752,7 +752,7 @@ public class LDClient { os_log("%s LDClient starting", log: config.logger, type: .debug, typeName(and: #function)) - let serviceFactory = serviceFactory ?? ClientServiceFactory(logger: config.logger, cacheBuilder: config.cacheBuilder) + let serviceFactory = serviceFactory ?? ClientServiceFactory(logger: config.logger, cacheFactory: config.cacheFactory) var keys = [config.mobileKey] keys.append(contentsOf: config.getSecondaryMobileKeys().values) serviceFactory.makeCacheConverter().convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts) diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index 0f04cf91..195f3c24 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -254,7 +254,7 @@ public struct LDConfig { static let logger: OSLog = OSLog(subsystem: "com.launchdarkly", category: "ios-client-sdk") /// The default cache for feature flags is UserDefaults - static let cacheBuilder: (String?) -> KeyedValueCaching = { cacheKey in + static let cacheFactory: CacheFactory = { _, cacheKey in UserDefaults(suiteName: cacheKey)! } @@ -433,7 +433,7 @@ public struct LDConfig { public var logger: OSLog = Defaults.logger /// Configure the persistent storage for caching flags locally - public var cacheBuilder: (String?) -> KeyedValueCaching = Defaults.cacheBuilder + public var cacheFactory: CacheFactory = Defaults.cacheFactory /// LaunchDarkly defined minima for selected configurable items public let minima: Minima diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift index e01871ec..ffab298d 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift @@ -1,5 +1,5 @@ import Foundation - +import OSLog // sourcery: autoMockable public protocol KeyedValueCaching { func set(_ value: Data, forKey: String) @@ -10,16 +10,6 @@ public protocol KeyedValueCaching { func keys() -> [String] } -extension UserDefaults: KeyedValueCaching { - public func set(_ value: Data, forKey: String) { - set(value as Any?, forKey: forKey) - } - - public func removeAll() { - dictionaryRepresentation().keys.forEach { removeObject(forKey: $0) } - } - - public func keys() -> [String] { - dictionaryRepresentation().keys.map { String($0) } - } +public extension LDConfig { + typealias CacheFactory = (_ logger: OSLog, _ cacheKey: String) -> KeyedValueCaching } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift index a16df4c2..169887ab 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -8,8 +8,8 @@ public final class LDInMemoryCache: KeyedValueCaching { private var cache: [String: Any] = [:] private var cacheLock = NSLock() - public static func builder() -> (String) -> LDInMemoryCache { - return { cacheKey in + public static func builder() -> LDConfig.CacheFactory { + return { _, cacheKey in instancesLock.lock() defer { instancesLock.unlock() } if let cache = instances[cacheKey] { return cache } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/UserDefaultsCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/UserDefaultsCache.swift new file mode 100644 index 00000000..a365062b --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/UserDefaultsCache.swift @@ -0,0 +1,15 @@ +import Foundation + +extension UserDefaults: KeyedValueCaching { + public func set(_ value: Data, forKey: String) { + set(value as Any?, forKey: forKey) + } + + public func removeAll() { + dictionaryRepresentation().keys.forEach { removeObject(forKey: $0) } + } + + public func keys() -> [String] { + dictionaryRepresentation().keys.map { String($0) } + } +} diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift index 2bb6fe17..50f7fb69 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift @@ -28,15 +28,15 @@ protocol ClientServiceCreating { final class ClientServiceFactory: ClientServiceCreating { private let logger: OSLog - private let cacheBuilder: (String?) -> KeyedValueCaching + private let cacheFactory: LDConfig.CacheFactory - init(logger: OSLog, cacheBuilder: @escaping ((String?) -> KeyedValueCaching)) { + init(logger: OSLog, cacheFactory: @escaping LDConfig.CacheFactory) { self.logger = logger - self.cacheBuilder = cacheBuilder + self.cacheFactory = cacheFactory } func makeKeyedValueCache(cacheKey: String?) -> KeyedValueCaching { - cacheBuilder(cacheKey) + cacheFactory(logger, cacheKey ?? "default") } func makeFeatureFlagCache(mobileKey: MobileKey, maxCachedContexts: Int) -> FeatureFlagCaching { diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift index 965d3f89..8c66238d 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift @@ -71,7 +71,7 @@ final class FeatureFlagCacheSpec: XCTestCase { func testCanReuseFullCacheIfHashIsSame() { let now = Date() - let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheBuilder: LDConfig.Defaults.cacheBuilder), mobileKey: "abc", maxCachedContexts: 5) + let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheFactory: LDConfig.Defaults.cacheFactory), mobileKey: "abc", maxCachedContexts: 5) flagCache.saveCachedData(testFlagCollection.flags, cacheKey: "key", contextHash: "hash", lastUpdated: now, etag: "example-etag") let results = flagCache.getCachedData(cacheKey: "key", contextHash: "hash") @@ -82,7 +82,7 @@ final class FeatureFlagCacheSpec: XCTestCase { func testCanReusePartialCacheIfOnlyHashChanges() { let now = Date() - let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheBuilder: LDConfig.Defaults.cacheBuilder), mobileKey: "abc", maxCachedContexts: 5) + let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheFactory: LDConfig.Defaults.cacheFactory), mobileKey: "abc", maxCachedContexts: 5) flagCache.saveCachedData(testFlagCollection.flags, cacheKey: "key", contextHash: "hash", lastUpdated: now, etag: "example-etag") let results = flagCache.getCachedData(cacheKey: "key", contextHash: "changed-hash") @@ -93,7 +93,7 @@ final class FeatureFlagCacheSpec: XCTestCase { func testCannotReuseCacheIfKeyChanges() { let now = Date() - let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheBuilder: LDConfig.Defaults.cacheBuilder), mobileKey: "abc", maxCachedContexts: 5) + let flagCache = FeatureFlagCache(serviceFactory: ClientServiceFactory(logger: .disabled, cacheFactory: LDConfig.Defaults.cacheFactory), mobileKey: "abc", maxCachedContexts: 5) flagCache.saveCachedData(testFlagCollection.flags, cacheKey: "key", contextHash: "hash", lastUpdated: now, etag: "example-etag") let results = flagCache.getCachedData(cacheKey: "changed-key", contextHash: "hash") diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift index 069c8946..1bc5a149 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -5,7 +5,7 @@ import XCTest final class UserDefaultsCachingSpec: KeyedValueCachingBaseSpec { override func makeSut(_ key: String) -> KeyedValueCaching { - return LDConfig.Defaults.cacheBuilder(key) + return LDConfig.Defaults.cacheFactory(.disabled, key) } } @@ -65,7 +65,7 @@ class KeyedValueCachingBaseSpec: XCTestCase { XCTAssertNil(makeSut("test").data(forKey: "test_key")) } - func testKeysGetter() throws { + func testKeys() throws { try skipForBaseSpec() let sut = makeSut("test") let keys = Array(0..<10).map { "key_\($0)" } From 54ac4c5760a68cca7bac9f48ce839e4d6f9bd25f Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 01:13:41 +0600 Subject: [PATCH 07/20] feat: [UXP-3578] Add file IO --- .../ServiceObjects/Cache/LDFileCache.swift | 71 +++++++++++++++---- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 3b5882f4..2c90f120 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -1,18 +1,20 @@ import Foundation +import OSLog public final class LDFileCache: KeyedValueCaching { private static let instancesLock = NSLock() private let cacheKey: String - private let inMemoryCache: LDInMemoryCache + private let inMemoryCache: KeyedValueCaching private let fileQueue = DispatchQueue(label: "ld_file_io", qos: .utility).debouncer() + private let logger: OSLog - public static func builder() -> (String) -> KeyedValueCaching { - return { cacheKey in + public static func builder() -> LDConfig.CacheFactory { + return { logger, cacheKey in instancesLock.lock() defer { instancesLock.unlock() } - let inMemoryCache = LDInMemoryCache.builder()(cacheKey) - let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache) + let inMemoryCache = LDInMemoryCache.builder()(logger, cacheKey) + let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, logger: logger) if inMemoryCache.data(forKey: "is_initialized") == nil { cache.deserializeFromFile() inMemoryCache.set(Data(), forKey: "is_initialized") @@ -51,22 +53,67 @@ public final class LDFileCache: KeyedValueCaching { // MARK: - Internal - init(cacheKey: String, inMemoryCache: LDInMemoryCache) { + init(cacheKey: String, inMemoryCache: KeyedValueCaching, logger: OSLog) { self.cacheKey = cacheKey self.inMemoryCache = inMemoryCache + self.logger = logger } - func deserializeFromFile() { - + func scheduleSerialization() { + fileQueue.debounce(interval: .milliseconds(500)) { [weak self] in + self?.serializeToFile() + } } func serializeToFile() { - + do { + var dictionary: [String: Data] = [:] + inMemoryCache.keys().forEach { key in + if let data = inMemoryCache.data(forKey: key) { + dictionary[key] = data + } + } + let data = try NSKeyedArchiver.archivedData(withRootObject: dictionary, requiringSecureCoding: true) + let url = try pathToFile() + try data.write(to: url, options: .atomic) + } catch { + os_log("%s failed writing cache to file. Error: %s", + log: logger, type: .debug, typeName, String(describing: error)) + } } - func scheduleSerialization() { - fileQueue.debounce(interval: .milliseconds(500)) { [weak self] in - self?.serializeToFile() + func deserializeFromFile() { + do { + let url = try pathToFile() + let data = try Data(contentsOf: url) + guard let flags = try NSKeyedUnarchiver + .unarchivedObject(ofClass: NSDictionary.self, from: data) as? [String: Data] + else { throw Error.cannotUnarchiveDictionary } + flags.forEach { key, value in + inMemoryCache.set(value, forKey: key) + } + } catch { + os_log("%s failed loading cache from file. Error: %s", + log: logger, type: .debug, typeName, String(describing: error)) } } + + func pathToFile() throws -> URL { + let fileManager = FileManager.default + guard let dir = fileManager + .urls(for: .libraryDirectory, in: .userDomainMask).first? + .appendingPathComponent("ld_cache") + else { throw Error.cannotAccessLibraryDirectory } + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent(cacheKey) + } +} + +extension LDFileCache: TypeIdentifying { } + +private extension LDFileCache { + enum Error: Swift.Error { + case cannotAccessLibraryDirectory + case cannotUnarchiveDictionary + } } From 2d41c91bc0e598af465142a8fd3bae90d8acf485 Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 02:15:58 +0600 Subject: [PATCH 08/20] feat: [UXP-3578] Add tests for file IO --- .../ServiceObjects/Cache/LDFileCache.swift | 20 ++++--- .../Cache/LDInMemoryCache.swift | 2 +- .../Cache/KeyedValueCachingSpec.swift | 52 ++++++++++++++++++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 2c90f120..92e13f70 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -9,15 +9,15 @@ public final class LDFileCache: KeyedValueCaching { private let fileQueue = DispatchQueue(label: "ld_file_io", qos: .utility).debouncer() private let logger: OSLog - public static func builder() -> LDConfig.CacheFactory { + public static func factory() -> LDConfig.CacheFactory { return { logger, cacheKey in instancesLock.lock() defer { instancesLock.unlock() } - let inMemoryCache = LDInMemoryCache.builder()(logger, cacheKey) + let inMemoryCache = LDInMemoryCache.factory()(logger, cacheKey) let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, logger: logger) - if inMemoryCache.data(forKey: "is_initialized") == nil { + if inMemoryCache.data(forKey: initializationKey) == nil { cache.deserializeFromFile() - inMemoryCache.set(Data(), forKey: "is_initialized") + inMemoryCache.set(Data(), forKey: initializationKey) } return cache } @@ -48,7 +48,7 @@ public final class LDFileCache: KeyedValueCaching { } public func keys() -> [String] { - return inMemoryCache.keys() + return inMemoryCache.keys().filter({ $0 != Self.initializationKey }) } // MARK: - Internal @@ -60,7 +60,7 @@ public final class LDFileCache: KeyedValueCaching { } func scheduleSerialization() { - fileQueue.debounce(interval: .milliseconds(500)) { [weak self] in + fileQueue.debounce(interval: Constants.writeToFileDelay) { [weak self] in self?.serializeToFile() } } @@ -73,6 +73,7 @@ public final class LDFileCache: KeyedValueCaching { dictionary[key] = data } } + dictionary.removeValue(forKey: Self.initializationKey) let data = try NSKeyedArchiver.archivedData(withRootObject: dictionary, requiringSecureCoding: true) let url = try pathToFile() try data.write(to: url, options: .atomic) @@ -107,13 +108,18 @@ public final class LDFileCache: KeyedValueCaching { try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent(cacheKey) } + + private static var initializationKey: String { "LDFileCache_initialized" } } extension LDFileCache: TypeIdentifying { } -private extension LDFileCache { +extension LDFileCache { enum Error: Swift.Error { case cannotAccessLibraryDirectory case cannotUnarchiveDictionary } + enum Constants { + static var writeToFileDelay: DispatchTimeInterval { .milliseconds(300) } + } } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift index 169887ab..48947ea4 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -8,7 +8,7 @@ public final class LDInMemoryCache: KeyedValueCaching { private var cache: [String: Any] = [:] private var cacheLock = NSLock() - public static func builder() -> LDConfig.CacheFactory { + public static func factory() -> LDConfig.CacheFactory { return { _, cacheKey in instancesLock.lock() defer { instancesLock.unlock() } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift index 1bc5a149..956371fa 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -11,13 +11,61 @@ final class UserDefaultsCachingSpec: KeyedValueCachingBaseSpec { final class LDInMemoryCacheSpec: KeyedValueCachingBaseSpec { override func makeSut(_ key: String) -> KeyedValueCaching { - return LDInMemoryCache.builder()(key) + return LDInMemoryCache.factory()(.disabled, key) } } final class LDFileCacheSpec: KeyedValueCachingBaseSpec { + override func makeSut(_ key: String) -> KeyedValueCaching { - return LDFileCache.builder()(key) + return LDFileCache.factory()(.disabled, key) + } + + private func makeFileSut(_ key: String) -> LDFileCache { + return makeSut(key) as! LDFileCache + } + + func testPathToFile() throws { + let sut1 = makeFileSut("test1") + let sut2 = makeFileSut("test2") + XCTAssertNotEqual(try sut1.pathToFile(), try sut2.pathToFile()) + } + + func testCorruptFile() throws { + let sut = makeFileSut(#function) + let url = try sut.pathToFile() + try Data("corrupt".utf8).write(to: url, options: .atomic) + sut.deserializeFromFile() + XCTAssertEqual(sut.keys(), []) + } + + func testSerialization() throws { + let dict: [String: Data] = [ + "key1": try JSONSerialization.data(withJSONObject: [ + "jsonKey1": 42, + "jsonKey2": "a string", + "jsonKey3": ["a null": NSNull()] + ]), + "key2": Data("random 🔥".utf8), + ] + let sut = makeFileSut(#function) + dict.forEach { key, value in + sut.set(value, forKey: key) + } + let exp = XCTestExpectation(description: #function) + let delay = DispatchTime.now() + LDFileCache.Constants.writeToFileDelay + .milliseconds(200) + DispatchQueue.main.asyncAfter(deadline: delay) { + sut.removeAll() + XCTAssertEqual(sut.keys(), []) + sut.deserializeFromFile() + let keys = sut.keys() + XCTAssertEqual(Set(keys), Set(dict.keys)) + keys.forEach { key in + XCTAssertEqual(sut.data(forKey: key), dict[key]) + } + exp.fulfill() + } + wait(for: [exp], timeout: 1) } } From 72fbaaebcd87bbdb51e55bb51dd4471f7a54c3dd Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 02:26:14 +0600 Subject: [PATCH 09/20] refactor: [UXP-3578] Use JSONDecoder --- .../LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 92e13f70..012a9113 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -74,7 +74,7 @@ public final class LDFileCache: KeyedValueCaching { } } dictionary.removeValue(forKey: Self.initializationKey) - let data = try NSKeyedArchiver.archivedData(withRootObject: dictionary, requiringSecureCoding: true) + let data = try JSONEncoder().encode(dictionary) let url = try pathToFile() try data.write(to: url, options: .atomic) } catch { @@ -87,9 +87,7 @@ public final class LDFileCache: KeyedValueCaching { do { let url = try pathToFile() let data = try Data(contentsOf: url) - guard let flags = try NSKeyedUnarchiver - .unarchivedObject(ofClass: NSDictionary.self, from: data) as? [String: Data] - else { throw Error.cannotUnarchiveDictionary } + let flags = try JSONDecoder().decode([String: Data].self, from: data) flags.forEach { key, value in inMemoryCache.set(value, forKey: key) } @@ -117,7 +115,6 @@ extension LDFileCache: TypeIdentifying { } extension LDFileCache { enum Error: Swift.Error { case cannotAccessLibraryDirectory - case cannotUnarchiveDictionary } enum Constants { static var writeToFileDelay: DispatchTimeInterval { .milliseconds(300) } From 66fa65aa13d675ef80d8ab551708f8e493b37a7f Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 11:48:13 +0600 Subject: [PATCH 10/20] chore: [UXP-3578] Remove unused method --- LaunchDarkly/GeneratedCode/mocks.generated.swift | 11 ----------- .../ServiceObjects/Cache/KeyedValueCache.swift | 1 - .../ServiceObjects/Cache/LDFileCache.swift | 5 ----- .../ServiceObjects/Cache/LDInMemoryCache.swift | 5 ----- .../ServiceObjects/Cache/KeyedValueCachingSpec.swift | 5 ----- 5 files changed, 27 deletions(-) diff --git a/LaunchDarkly/GeneratedCode/mocks.generated.swift b/LaunchDarkly/GeneratedCode/mocks.generated.swift index 4d9d2294..1c5573c6 100644 --- a/LaunchDarkly/GeneratedCode/mocks.generated.swift +++ b/LaunchDarkly/GeneratedCode/mocks.generated.swift @@ -353,17 +353,6 @@ final class KeyedValueCachingMock: KeyedValueCaching { return dataReturnValue } - var dictionaryCallCount = 0 - var dictionaryCallback: (() throws -> Void)? - var dictionaryReceivedForKey: String? - var dictionaryReturnValue: [String: Any]? - func dictionary(forKey: String) -> [String: Any]? { - dictionaryCallCount += 1 - dictionaryReceivedForKey = forKey - try! dictionaryCallback?() - return dictionaryReturnValue - } - var removeObjectCallCount = 0 var removeObjectCallback: (() throws -> Void)? var removeObjectReceivedForKey: String? diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift index ffab298d..e4cc0c15 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift @@ -4,7 +4,6 @@ import OSLog public protocol KeyedValueCaching { func set(_ value: Data, forKey: String) func data(forKey: String) -> Data? - func dictionary(forKey: String) -> [String: Any]? func removeObject(forKey: String) func removeAll() func keys() -> [String] diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 012a9113..e658913b 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -32,11 +32,6 @@ public final class LDFileCache: KeyedValueCaching { return inMemoryCache.data(forKey: forKey) } - public func dictionary(forKey: String) -> [String : Any]? { - // Legacy - not used by the library - return nil - } - public func removeObject(forKey: String) { inMemoryCache.removeObject(forKey: forKey) scheduleSerialization() diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift index 48947ea4..d12be800 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -31,11 +31,6 @@ public final class LDInMemoryCache: KeyedValueCaching { return cache[forKey] as? Data } - public func dictionary(forKey: String) -> [String : Any]? { - // Legacy - not used by the library - return nil - } - public func removeObject(forKey: String) { cacheLock.lock() defer { cacheLock.unlock() } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift index 956371fa..1f5892e1 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -92,11 +92,6 @@ class KeyedValueCachingBaseSpec: XCTestCase { XCTAssertEqual(makeSut("test").data(forKey: "test_key"), data) } - func testDictionaryForKey() throws { - try skipForBaseSpec() - // No-op: no public setter declared in the protocol, unused in the library - } - func testRemoveObjectForKey() throws { try skipForBaseSpec() let data = Data("random".utf8) From 354412f038d36813ebbf0fd987f19824597abcbb Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 11:50:54 +0600 Subject: [PATCH 11/20] refactor: [UXP-3578] Reorder factory params --- LaunchDarkly/LaunchDarkly/Models/LDConfig.swift | 2 +- .../LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift | 2 +- .../LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift | 4 ++-- .../LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift | 2 +- .../LaunchDarkly/ServiceObjects/ClientServiceFactory.swift | 2 +- .../ServiceObjects/Cache/KeyedValueCachingSpec.swift | 6 +++--- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index 195f3c24..a44a3d21 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -254,7 +254,7 @@ public struct LDConfig { static let logger: OSLog = OSLog(subsystem: "com.launchdarkly", category: "ios-client-sdk") /// The default cache for feature flags is UserDefaults - static let cacheFactory: CacheFactory = { _, cacheKey in + static let cacheFactory: CacheFactory = { cacheKey, _ in UserDefaults(suiteName: cacheKey)! } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift index e4cc0c15..6003031b 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift @@ -10,5 +10,5 @@ public protocol KeyedValueCaching { } public extension LDConfig { - typealias CacheFactory = (_ logger: OSLog, _ cacheKey: String) -> KeyedValueCaching + typealias CacheFactory = (_ cacheKey: String, _ logger: OSLog) -> KeyedValueCaching } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index e658913b..d9a72485 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -10,10 +10,10 @@ public final class LDFileCache: KeyedValueCaching { private let logger: OSLog public static func factory() -> LDConfig.CacheFactory { - return { logger, cacheKey in + return { cacheKey, logger in instancesLock.lock() defer { instancesLock.unlock() } - let inMemoryCache = LDInMemoryCache.factory()(logger, cacheKey) + let inMemoryCache = LDInMemoryCache.factory()(cacheKey, logger) let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, logger: logger) if inMemoryCache.data(forKey: initializationKey) == nil { cache.deserializeFromFile() diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift index d12be800..90be7426 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -9,7 +9,7 @@ public final class LDInMemoryCache: KeyedValueCaching { private var cacheLock = NSLock() public static func factory() -> LDConfig.CacheFactory { - return { _, cacheKey in + return { cacheKey, _ in instancesLock.lock() defer { instancesLock.unlock() } if let cache = instances[cacheKey] { return cache } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift index 50f7fb69..007be0b0 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift @@ -36,7 +36,7 @@ final class ClientServiceFactory: ClientServiceCreating { } func makeKeyedValueCache(cacheKey: String?) -> KeyedValueCaching { - cacheFactory(logger, cacheKey ?? "default") + cacheFactory(cacheKey ?? "default", logger) } func makeFeatureFlagCache(mobileKey: MobileKey, maxCachedContexts: Int) -> FeatureFlagCaching { diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift index 1f5892e1..8d89f3f4 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -5,20 +5,20 @@ import XCTest final class UserDefaultsCachingSpec: KeyedValueCachingBaseSpec { override func makeSut(_ key: String) -> KeyedValueCaching { - return LDConfig.Defaults.cacheFactory(.disabled, key) + return LDConfig.Defaults.cacheFactory(key, .disabled) } } final class LDInMemoryCacheSpec: KeyedValueCachingBaseSpec { override func makeSut(_ key: String) -> KeyedValueCaching { - return LDInMemoryCache.factory()(.disabled, key) + return LDInMemoryCache.factory()(key, .disabled) } } final class LDFileCacheSpec: KeyedValueCachingBaseSpec { override func makeSut(_ key: String) -> KeyedValueCaching { - return LDFileCache.factory()(.disabled, key) + return LDFileCache.factory()(key, .disabled) } private func makeFileSut(_ key: String) -> LDFileCache { From e238ab9bdeb5434552a23825c266639d2470f1df Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 12:24:01 +0600 Subject: [PATCH 12/20] feat: [UXP-3578] Tweak the tests --- .../ServiceObjects/Cache/KeyedValueCachingSpec.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift index 8d89f3f4..17352ded 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -33,6 +33,7 @@ final class LDFileCacheSpec: KeyedValueCachingBaseSpec { func testCorruptFile() throws { let sut = makeFileSut(#function) + sut.set(Data(), forKey: "key") let url = try sut.pathToFile() try Data("corrupt".utf8).write(to: url, options: .atomic) sut.deserializeFromFile() @@ -47,6 +48,7 @@ final class LDFileCacheSpec: KeyedValueCachingBaseSpec { "jsonKey3": ["a null": NSNull()] ]), "key2": Data("random 🔥".utf8), + "%^&*() !@#": try NSKeyedArchiver.archivedData(withRootObject: ["key": "value"], requiringSecureCoding: true), ] let sut = makeFileSut(#function) dict.forEach { key, value in From 560b689b831a7f12a901093454bf90f7d9d223fa Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 13:29:08 +0600 Subject: [PATCH 13/20] feat: [UXP-3578] Support file encryption --- .../ServiceObjects/Cache/LDFileCache.swift | 18 +++++-- LaunchDarkly/LaunchDarkly/Util.swift | 47 +++++++++++++++++++ .../Cache/KeyedValueCachingSpec.swift | 4 +- LaunchDarkly/LaunchDarklyTests/UtilSpec.swift | 7 +++ 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index d9a72485..03de5522 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -5,16 +5,17 @@ public final class LDFileCache: KeyedValueCaching { private static let instancesLock = NSLock() private let cacheKey: String + private let encryptionKey: String? private let inMemoryCache: KeyedValueCaching private let fileQueue = DispatchQueue(label: "ld_file_io", qos: .utility).debouncer() private let logger: OSLog - public static func factory() -> LDConfig.CacheFactory { + public static func factory(encryptionKey: String? = nil) -> LDConfig.CacheFactory { return { cacheKey, logger in instancesLock.lock() defer { instancesLock.unlock() } let inMemoryCache = LDInMemoryCache.factory()(cacheKey, logger) - let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, logger: logger) + let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, encryptionKey: encryptionKey, logger: logger) if inMemoryCache.data(forKey: initializationKey) == nil { cache.deserializeFromFile() inMemoryCache.set(Data(), forKey: initializationKey) @@ -48,9 +49,10 @@ public final class LDFileCache: KeyedValueCaching { // MARK: - Internal - init(cacheKey: String, inMemoryCache: KeyedValueCaching, logger: OSLog) { + init(cacheKey: String, inMemoryCache: KeyedValueCaching, encryptionKey: String?, logger: OSLog) { self.cacheKey = cacheKey self.inMemoryCache = inMemoryCache + self.encryptionKey = encryptionKey self.logger = logger } @@ -69,7 +71,10 @@ public final class LDFileCache: KeyedValueCaching { } } dictionary.removeValue(forKey: Self.initializationKey) - let data = try JSONEncoder().encode(dictionary) + var data = try JSONEncoder().encode(dictionary) + if let encryptionKey { + data = try Util.encrypt(data, encryptionKey: encryptionKey, cacheKey: cacheKey) + } let url = try pathToFile() try data.write(to: url, options: .atomic) } catch { @@ -81,7 +86,10 @@ public final class LDFileCache: KeyedValueCaching { func deserializeFromFile() { do { let url = try pathToFile() - let data = try Data(contentsOf: url) + var data = try Data(contentsOf: url) + if let encryptionKey { + data = try Util.decrypt(data, encryptionKey: encryptionKey, cacheKey: cacheKey) + } let flags = try JSONDecoder().decode([String: Data].self, from: data) flags.forEach { key, value in inMemoryCache.set(value, forKey: key) diff --git a/LaunchDarkly/LaunchDarkly/Util.swift b/LaunchDarkly/LaunchDarkly/Util.swift index b40c7115..625fecf9 100644 --- a/LaunchDarkly/LaunchDarkly/Util.swift +++ b/LaunchDarkly/LaunchDarkly/Util.swift @@ -3,6 +3,11 @@ import Foundation import Dispatch class Util { + enum Error: Swift.Error { + case keyGeneration + case commonCrypto(status: CCCryptorStatus) + } + internal static let validKindCharacterSet = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") internal static let validTagCharacterSet = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") @@ -18,6 +23,48 @@ class Util { } return Data(digest) } + + class func encrypt(_ data: Data, encryptionKey: String, cacheKey: String) throws -> Data { + let (key, iv) = try keyAndIV(encryptionKey: encryptionKey, cacheKey: cacheKey) + return try crypt(operation: CCOperation(kCCEncrypt), data: data, key: key, iv: iv) + } + + class func decrypt(_ data: Data, encryptionKey: String, cacheKey: String) throws -> Data { + let (key, iv) = try keyAndIV(encryptionKey: encryptionKey, cacheKey: cacheKey) + return try crypt(operation: CCOperation(kCCDecrypt), data: data, key: key, iv: iv) + } + + private class func keyAndIV(encryptionKey: String, cacheKey: String) throws -> (key: Data, iv: Data) { + guard let key = (encryptionKey + "salt").data(using: .utf8), + let iv = (encryptionKey + cacheKey).data(using: .utf8) + else { throw Error.keyGeneration } + return (key, iv) + } + + private class func crypt(operation: CCOperation, data: Data, key: Data, iv: Data) throws -> Data { + let cryptLength = size_t(data.count + kCCBlockSizeAES128) + var cryptData = Data(count: cryptLength) + let keyLength = size_t(kCCKeySizeAES128) + let options = CCOptions(kCCOptionPKCS7Padding) + var numBytesEncrypted: size_t = 0 + let cryptStatus = cryptData.withUnsafeMutableBytes { cryptBytes in + data.withUnsafeBytes { dataBytes in + iv.withUnsafeBytes { ivBytes in + key.withUnsafeBytes { keyBytes in + CCCrypt(operation, CCAlgorithm(kCCAlgorithmAES), options, + keyBytes.baseAddress, keyLength, ivBytes.baseAddress, + dataBytes.baseAddress, data.count, cryptBytes.baseAddress, + cryptLength, &numBytesEncrypted) + } + } + } + } + guard UInt32(cryptStatus) == UInt32(kCCSuccess) else { + throw Error.commonCrypto(status: cryptStatus) + } + cryptData.removeSubrange(numBytesEncrypted.. KeyedValueCaching { - return LDFileCache.factory()(key, .disabled) + return LDFileCache.factory(encryptionKey: "test_secret")(key, .disabled) } private func makeFileSut(_ key: String) -> LDFileCache { @@ -37,7 +37,7 @@ final class LDFileCacheSpec: KeyedValueCachingBaseSpec { let url = try sut.pathToFile() try Data("corrupt".utf8).write(to: url, options: .atomic) sut.deserializeFromFile() - XCTAssertEqual(sut.keys(), []) + XCTAssertEqual(sut.keys(), ["key"]) } func testSerialization() throws { diff --git a/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift b/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift index 2f56e9d6..b989ca90 100644 --- a/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift @@ -19,6 +19,13 @@ final class UtilSpec: XCTestCase { XCTAssertEqual(output, expectedOutput) } + func testDataEncryption() throws { + let data = Data((0 ..< 10000).map { _ in UInt8.random(in: UInt8.min ... UInt8.max) }) + let encryptedData = try Util.encrypt(data, encryptionKey: "test_pwd", cacheKey: "abc") + let decryptedData = try Util.decrypt(encryptedData, encryptionKey: "test_pwd", cacheKey: "abc") + XCTAssertEqual(decryptedData, data) + } + func testDispatchQueueDebounceConcurrentRequests() { let exp = XCTestExpectation(description: #function) let queue = DispatchQueue(label: "test") From 889d9ce07bd7b26bcf04134b76332c2c3cceaeae Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 13:45:58 +0600 Subject: [PATCH 14/20] chore: [UXP-3578] Add files to project --- LaunchDarkly.xcodeproj/project.pbxproj | 37 +++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index e537f7f6..818d1201 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -21,6 +21,20 @@ 3D3AB9462A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; }; 3D3AB9482A570F3A003AECF1 /* ModifierSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9472A570F3A003AECF1 /* ModifierSpec.swift */; }; 3D9A12582A73236800698B8D /* UtilSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9A12572A73236800698B8D /* UtilSpec.swift */; }; + 48761C9F2CCA310400561EC4 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; productRef = A3F4A4802CC2F640006EF480 /* CwlPreconditionTesting */; }; + 48761CA32CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; }; + 48761CA42CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; }; + 48761CA52CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; }; + 48761CA62CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; }; + 48761CA72CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; }; + 48761CA82CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; }; + 48761CA92CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; }; + 48761CAA2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; }; + 48761CAB2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; }; + 48761CAC2CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; }; + 48761CAD2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; }; + 48761CAE2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; }; + 48761CB02CCA322700561EC4 /* KeyedValueCachingSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CAF2CCA322700561EC4 /* KeyedValueCachingSpec.swift */; }; 830BF933202D188E006DF9B1 /* HTTPURLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830BF932202D188E006DF9B1 /* HTTPURLRequest.swift */; }; 830DB3AC22380A3E00D65D25 /* HTTPHeadersSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AB22380A3E00D65D25 /* HTTPHeadersSpec.swift */; }; 830DB3AE2239B54900D65D25 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AD2239B54900D65D25 /* URLResponse.swift */; }; @@ -406,6 +420,10 @@ 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportingConsts.swift; sourceTree = ""; }; 3D3AB9472A570F3A003AECF1 /* ModifierSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifierSpec.swift; sourceTree = ""; }; 3D9A12572A73236800698B8D /* UtilSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilSpec.swift; sourceTree = ""; }; + 48761CA02CCA31B100561EC4 /* LDFileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDFileCache.swift; sourceTree = ""; }; + 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDInMemoryCache.swift; sourceTree = ""; }; + 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsCache.swift; sourceTree = ""; }; + 48761CAF2CCA322700561EC4 /* KeyedValueCachingSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyedValueCachingSpec.swift; sourceTree = ""; }; 830BF932202D188E006DF9B1 /* HTTPURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPURLRequest.swift; sourceTree = ""; }; 830DB3AB22380A3E00D65D25 /* HTTPHeadersSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeadersSpec.swift; sourceTree = ""; }; 830DB3AD2239B54900D65D25 /* URLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLResponse.swift; sourceTree = ""; }; @@ -560,10 +578,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 48761C9F2CCA310400561EC4 /* CwlPreconditionTesting in Frameworks */, B4903D9E24BD61EF00F087C4 /* Quick in Frameworks */, B4903D9B24BD61D000F087C4 /* Nimble in Frameworks */, B4903D9824BD61B200F087C4 /* OHHTTPStubsSwift in Frameworks */, - A3F4A4812CC2F640006EF480 /* CwlPreconditionTesting in Frameworks */, 8354EFCC1F22491C00C05156 /* LaunchDarkly.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -636,6 +654,9 @@ C408884623033B3600420721 /* ConnectionInformationStore.swift */, 83D559731FD87CC9002D10C8 /* KeyedValueCache.swift */, 8354AC6F2243166900CDE602 /* FeatureFlagCache.swift */, + 48761CA02CCA31B100561EC4 /* LDFileCache.swift */, + 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */, + 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */, 832D68A1224A38FC005F052A /* CacheConverter.swift */, B4C9D4322489C8FD004A9B03 /* DiagnosticCache.swift */, ); @@ -645,6 +666,7 @@ 8354AC75224316C700CDE602 /* Cache */ = { isa = PBXGroup; children = ( + 48761CAF2CCA322700561EC4 /* KeyedValueCachingSpec.swift */, 8354AC76224316F800CDE602 /* FeatureFlagCacheSpec.swift */, 832D68AB224B3321005F052A /* CacheConverterSpec.swift */, B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */, @@ -1302,6 +1324,9 @@ 83906A7B21190B7700D7D3C5 /* DateFormatter.swift in Sources */, 831188502113ADEF00D77CB5 /* EnvironmentReporter.swift in Sources */, 831188682113AE5600D77CB5 /* ObjcLDClient.swift in Sources */, + 48761CAC2CCA31B100561EC4 /* LDFileCache.swift in Sources */, + 48761CAD2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */, + 48761CAE2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */, 831188572113AE0B00D77CB5 /* FlagChangeNotifier.swift in Sources */, 8311884D2113ADE200D77CB5 /* FlagsUnchangedObserver.swift in Sources */, 8311885F2113AE2D00D77CB5 /* HTTPURLRequest.swift in Sources */, @@ -1413,6 +1438,9 @@ 831EF35920655E730001C643 /* Log.swift in Sources */, A358D6E42A4DE98300270C60 /* MacOSEnvironmentReporter.swift in Sources */, 831EF35A20655E730001C643 /* HTTPHeaders.swift in Sources */, + 48761CA62CCA31B100561EC4 /* LDFileCache.swift in Sources */, + 48761CA72CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */, + 48761CA82CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */, 831EF35B20655E730001C643 /* DarklyService.swift in Sources */, 831EF35C20655E730001C643 /* HTTPURLResponse.swift in Sources */, C443A40723145FEE00145710 /* ConnectionInformationStore.swift in Sources */, @@ -1447,6 +1475,9 @@ 831D8B6F1F71532300ED65E8 /* HTTPHeaders.swift in Sources */, 835E1D3F1F63450A00184DB4 /* ObjcLDClient.swift in Sources */, 83EBCBB320DABE1B003A7142 /* FlagRequestTracker.swift in Sources */, + 48761CA92CCA31B100561EC4 /* LDFileCache.swift in Sources */, + 48761CAA2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */, + 48761CAB2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */, 837EF3742059C237009D628A /* Log.swift in Sources */, 83FEF8DD1F266742001CF12C /* FlagSynchronizer.swift in Sources */, 830BF933202D188E006DF9B1 /* HTTPURLRequest.swift in Sources */, @@ -1529,6 +1560,7 @@ A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */, 83B6E3F1222EFA3800FF2A6A /* ThreadSpec.swift in Sources */, 831AAE3020A9E75D00B46DBA /* ThrottlerSpec.swift in Sources */, + 48761CB02CCA322700561EC4 /* KeyedValueCachingSpec.swift in Sources */, 832D68AC224B3321005F052A /* CacheConverterSpec.swift in Sources */, 838F96741FB9F024009CFC45 /* LDClientSpec.swift in Sources */, 83A0E6B1203B557F00224298 /* FeatureFlagSpec.swift in Sources */, @@ -1573,6 +1605,9 @@ 83D9EC752062DEAB004D7FA6 /* LDCommon.swift in Sources */, 83D9EC762062DEAB004D7FA6 /* LDConfig.swift in Sources */, 83EBCBB420DABE1B003A7142 /* FlagRequestTracker.swift in Sources */, + 48761CA32CCA31B100561EC4 /* LDFileCache.swift in Sources */, + 48761CA42CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */, + 48761CA52CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */, 83D9EC772062DEAB004D7FA6 /* LDClient.swift in Sources */, B4C9D4342489C8FD004A9B03 /* DiagnosticCache.swift in Sources */, 83D9EC7C2062DEAB004D7FA6 /* FeatureFlag.swift in Sources */, From 01ee468efa69eb9b7d2a8851aaba9cad36b36d1f Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 14:50:30 +0600 Subject: [PATCH 15/20] fix: [UXP-3578] Make cacheKey optional --- .../LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift | 2 +- .../LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift | 1 + .../LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift | 1 + .../LaunchDarkly/ServiceObjects/ClientServiceFactory.swift | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift index 6003031b..c9ac4e3d 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift @@ -10,5 +10,5 @@ public protocol KeyedValueCaching { } public extension LDConfig { - typealias CacheFactory = (_ cacheKey: String, _ logger: OSLog) -> KeyedValueCaching + typealias CacheFactory = (_ cacheKey: String?, _ logger: OSLog) -> KeyedValueCaching } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 03de5522..65ef9888 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -14,6 +14,7 @@ public final class LDFileCache: KeyedValueCaching { return { cacheKey, logger in instancesLock.lock() defer { instancesLock.unlock() } + let cacheKey = cacheKey ?? "default" let inMemoryCache = LDInMemoryCache.factory()(cacheKey, logger) let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, encryptionKey: encryptionKey, logger: logger) if inMemoryCache.data(forKey: initializationKey) == nil { diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift index 90be7426..198f857f 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -12,6 +12,7 @@ public final class LDInMemoryCache: KeyedValueCaching { return { cacheKey, _ in instancesLock.lock() defer { instancesLock.unlock() } + let cacheKey = cacheKey ?? "default" if let cache = instances[cacheKey] { return cache } let cache = LDInMemoryCache() instances[cacheKey] = cache diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift index 007be0b0..25d4b293 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/ClientServiceFactory.swift @@ -36,7 +36,7 @@ final class ClientServiceFactory: ClientServiceCreating { } func makeKeyedValueCache(cacheKey: String?) -> KeyedValueCaching { - cacheFactory(cacheKey ?? "default", logger) + cacheFactory(cacheKey, logger) } func makeFeatureFlagCache(mobileKey: MobileKey, maxCachedContexts: Int) -> FeatureFlagCaching { From 43bac26f77eceaeeb1a3e5deccc1dad23dbfbf1e Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 15:11:20 +0600 Subject: [PATCH 16/20] feat: [UXP-3578] Add cache storage migration --- LaunchDarkly/LaunchDarkly/LDClient.swift | 4 +++- .../ServiceObjects/Cache/CacheConverter.swift | 15 ++++++++++++++ .../Cache/FeatureFlagCache.swift | 20 ++++++++++++------- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index c40e9355..e273ab38 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -755,7 +755,9 @@ public class LDClient { let serviceFactory = serviceFactory ?? ClientServiceFactory(logger: config.logger, cacheFactory: config.cacheFactory) var keys = [config.mobileKey] keys.append(contentsOf: config.getSecondaryMobileKeys().values) - serviceFactory.makeCacheConverter().convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts) + let cacheConverter = serviceFactory.makeCacheConverter() + cacheConverter.migrateStorage(serviceFactory: serviceFactory, keysToConvert: keys) + cacheConverter.convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts) var mobileKeys = config.getSecondaryMobileKeys() var internalCount = 0 let completionCheck = { diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift index 67365cb6..3963100c 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift @@ -2,6 +2,7 @@ import Foundation // sourcery: autoMockable protocol CacheConverting { + func migrateStorage(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey]) func convertCacheData(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int) } @@ -40,6 +41,20 @@ final class CacheConverter: CacheConverting { init() { } + func migrateStorage(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey]) { + keysToConvert.forEach { mobileKey in + let cacheKey = mobileKey.cacheKey() + let newCache = serviceFactory.makeKeyedValueCache(cacheKey: cacheKey) + guard !(newCache is UserDefaults) else { return } + let oldCache = LDConfig.Defaults.cacheFactory(cacheKey, .disabled) + oldCache.keys().forEach { key in + if let data = oldCache.data(forKey: key) { + newCache.set(data, forKey: key) + } + } + } + } + func convertCacheData(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int) { // Remove V5 cache data let standardDefaults = serviceFactory.makeKeyedValueCache(cacheKey: nil) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/FeatureFlagCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/FeatureFlagCache.swift index 43265f11..87601721 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/FeatureFlagCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/FeatureFlagCache.swift @@ -57,18 +57,24 @@ protocol FeatureFlagCaching { func saveCachedData(_ storedItems: StoredItems, cacheKey: String, contextHash: String, lastUpdated: Date, etag: String?) } +extension MobileKey { + func cacheKey() -> String { + let cacheKey: String + if let bundleId = Bundle.main.bundleIdentifier { + cacheKey = "\(Util.sha256base64(bundleId)).\(Util.sha256base64(self))" + } else { + cacheKey = Util.sha256base64(self) + } + return "com.launchdarkly.client.\(cacheKey)" + } +} + final class FeatureFlagCache: FeatureFlagCaching { let keyedValueCache: KeyedValueCaching let maxCachedContexts: Int init(serviceFactory: ClientServiceCreating, mobileKey: MobileKey, maxCachedContexts: Int) { - let cacheKey: String - if let bundleId = Bundle.main.bundleIdentifier { - cacheKey = "\(Util.sha256base64(bundleId)).\(Util.sha256base64(mobileKey))" - } else { - cacheKey = Util.sha256base64(mobileKey) - } - self.keyedValueCache = serviceFactory.makeKeyedValueCache(cacheKey: "com.launchdarkly.client.\(cacheKey)") + self.keyedValueCache = serviceFactory.makeKeyedValueCache(cacheKey: mobileKey.cacheKey()) self.maxCachedContexts = maxCachedContexts } From 84673e0d5e638591e10a778abcdbb82641fce274 Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 15:47:05 +0600 Subject: [PATCH 17/20] feat: [UXP-3578] Add cache storage migration test --- LaunchDarkly/GeneratedCode/mocks.generated.swift | 9 +++++++++ LaunchDarkly/LaunchDarkly/LDClient.swift | 2 +- .../ServiceObjects/Cache/CacheConverter.swift | 10 +++++----- .../ServiceObjects/Cache/CacheConverterSpec.swift | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/LaunchDarkly/GeneratedCode/mocks.generated.swift b/LaunchDarkly/GeneratedCode/mocks.generated.swift index 1c5573c6..977edb30 100644 --- a/LaunchDarkly/GeneratedCode/mocks.generated.swift +++ b/LaunchDarkly/GeneratedCode/mocks.generated.swift @@ -10,6 +10,15 @@ import LDSwiftEventSource // MARK: - CacheConvertingMock final class CacheConvertingMock: CacheConverting { + var migrateStorageCallCount = 0 + var migrateStorageCallback: (() throws -> Void)? + var migrateStorageReceivedArguments: (serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], oldCache: LDConfig.CacheFactory)? + func migrateStorage(serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], from oldCache: @escaping LDConfig.CacheFactory) { + migrateStorageCallCount += 1 + migrateStorageReceivedArguments = (serviceFactory: serviceFactory, keysToMigrate: keysToMigrate, oldCache: oldCache) + try! migrateStorageCallback?() + } + var convertCacheDataCallCount = 0 var convertCacheDataCallback: (() throws -> Void)? var convertCacheDataReceivedArguments: (serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int)? diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index e273ab38..56cd0268 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -756,7 +756,7 @@ public class LDClient { var keys = [config.mobileKey] keys.append(contentsOf: config.getSecondaryMobileKeys().values) let cacheConverter = serviceFactory.makeCacheConverter() - cacheConverter.migrateStorage(serviceFactory: serviceFactory, keysToConvert: keys) + cacheConverter.migrateStorage(serviceFactory: serviceFactory, keysToMigrate: keys, from: LDConfig.Defaults.cacheFactory) cacheConverter.convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts) var mobileKeys = config.getSecondaryMobileKeys() var internalCount = 0 diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift index 3963100c..a1ff3690 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift @@ -2,7 +2,7 @@ import Foundation // sourcery: autoMockable protocol CacheConverting { - func migrateStorage(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey]) + func migrateStorage(serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], from oldCache: @escaping LDConfig.CacheFactory) func convertCacheData(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int) } @@ -41,12 +41,12 @@ final class CacheConverter: CacheConverting { init() { } - func migrateStorage(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey]) { - keysToConvert.forEach { mobileKey in + func migrateStorage(serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], from oldCacheFactory: @escaping LDConfig.CacheFactory) { + keysToMigrate.forEach { mobileKey in let cacheKey = mobileKey.cacheKey() let newCache = serviceFactory.makeKeyedValueCache(cacheKey: cacheKey) - guard !(newCache is UserDefaults) else { return } - let oldCache = LDConfig.Defaults.cacheFactory(cacheKey, .disabled) + guard newCache.keys().isEmpty else { return } + let oldCache = oldCacheFactory(cacheKey, .disabled) oldCache.keys().forEach { key in if let data = oldCache.data(forKey: key) { newCache.set(data, forKey: key) diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/CacheConverterSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/CacheConverterSpec.swift index af72ee03..2edd178d 100644 --- a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/CacheConverterSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/CacheConverterSpec.swift @@ -34,4 +34,18 @@ final class CacheConverterSpec: XCTestCase { XCTAssertEqual(serviceFactory.makeFeatureFlagCacheCallCount, 2) XCTAssertEqual(v7valueCacheMock.dataCallCount, 2) } + + func testCacheStoreMigration() { + let oldCache = LDInMemoryCache() + oldCache.set(Data("test_1".utf8), forKey: "data_1") + oldCache.set(Data("test_2".utf8), forKey: "data_2") + oldCache.set(Data("test_3".utf8), forKey: "data_3") + let newCache = KeyedValueCachingMock() + newCache.keysReturnValue = [] + serviceFactory.makeFeatureFlagCacheReturnValue.keyedValueCache = newCache + serviceFactory.makeKeyedValueCacheReturnValue = newCache + CacheConverter().migrateStorage(serviceFactory: serviceFactory, keysToMigrate: ["key1", "key2"], from: { _, _ in oldCache }) + XCTAssertEqual(serviceFactory.makeKeyedValueCacheCallCount, 2) + XCTAssertEqual(newCache.setCallCount, 6) + } } From cc331e31c4d117e550d010c3f7ac26642f46448e Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Thu, 24 Oct 2024 17:15:39 +0600 Subject: [PATCH 18/20] feat: [UXP-3578] Use sha256hex as file name --- .../LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift | 3 ++- LaunchDarkly/LaunchDarkly/Util.swift | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 65ef9888..1909ebd7 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -108,7 +108,8 @@ public final class LDFileCache: KeyedValueCaching { .appendingPathComponent("ld_cache") else { throw Error.cannotAccessLibraryDirectory } try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent(cacheKey) + let fileName = Util.sha256hex(cacheKey) + return dir.appendingPathComponent(fileName) } private static var initializationKey: String { "LDFileCache_initialized" } diff --git a/LaunchDarkly/LaunchDarkly/Util.swift b/LaunchDarkly/LaunchDarkly/Util.swift index 625fecf9..ffbbc701 100644 --- a/LaunchDarkly/LaunchDarkly/Util.swift +++ b/LaunchDarkly/LaunchDarkly/Util.swift @@ -15,6 +15,10 @@ class Util { sha256(str).base64EncodedString() } + class func sha256hex(_ str: String) -> String { + sha256(str).map { String(format: "%02hhX", $0) }.joined() + } + class func sha256(_ str: String) -> Data { let data = Data(str.utf8) var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) From 3cf5caee3b9dd0e7cda58e34021fa9f728c4022c Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Fri, 25 Oct 2024 23:54:35 +0600 Subject: [PATCH 19/20] fix: [UXP-3578] FileQueue not retained --- .../LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 1909ebd7..11d0e150 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -3,11 +3,13 @@ import OSLog public final class LDFileCache: KeyedValueCaching { + private static var instances: [String: LDFileCache] = [:] private static let instancesLock = NSLock() + private static let fileQueue = DispatchQueue(label: "ld_file_io", qos: .utility) private let cacheKey: String private let encryptionKey: String? private let inMemoryCache: KeyedValueCaching - private let fileQueue = DispatchQueue(label: "ld_file_io", qos: .utility).debouncer() + private let fileIO = fileQueue.debouncer() private let logger: OSLog public static func factory(encryptionKey: String? = nil) -> LDConfig.CacheFactory { @@ -15,12 +17,14 @@ public final class LDFileCache: KeyedValueCaching { instancesLock.lock() defer { instancesLock.unlock() } let cacheKey = cacheKey ?? "default" + if let cache = instances[cacheKey] { return cache } let inMemoryCache = LDInMemoryCache.factory()(cacheKey, logger) let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, encryptionKey: encryptionKey, logger: logger) if inMemoryCache.data(forKey: initializationKey) == nil { cache.deserializeFromFile() inMemoryCache.set(Data(), forKey: initializationKey) } + instances[cacheKey] = cache return cache } } @@ -58,7 +62,7 @@ public final class LDFileCache: KeyedValueCaching { } func scheduleSerialization() { - fileQueue.debounce(interval: Constants.writeToFileDelay) { [weak self] in + fileIO.debounce(interval: Constants.writeToFileDelay) { [weak self] in self?.serializeToFile() } } From 5f54439c980a6563d25f9c48f6854323dcb8f992 Mon Sep 17 00:00:00 2001 From: Alexey Naumov Date: Sat, 26 Oct 2024 12:24:29 +0600 Subject: [PATCH 20/20] chore: [UXP-3578] Remove unnecessary initializationKey --- .../ServiceObjects/Cache/LDFileCache.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift index 11d0e150..a892ee4b 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -20,10 +20,7 @@ public final class LDFileCache: KeyedValueCaching { if let cache = instances[cacheKey] { return cache } let inMemoryCache = LDInMemoryCache.factory()(cacheKey, logger) let cache = LDFileCache(cacheKey: cacheKey, inMemoryCache: inMemoryCache, encryptionKey: encryptionKey, logger: logger) - if inMemoryCache.data(forKey: initializationKey) == nil { - cache.deserializeFromFile() - inMemoryCache.set(Data(), forKey: initializationKey) - } + cache.deserializeFromFile() instances[cacheKey] = cache return cache } @@ -49,7 +46,7 @@ public final class LDFileCache: KeyedValueCaching { } public func keys() -> [String] { - return inMemoryCache.keys().filter({ $0 != Self.initializationKey }) + return inMemoryCache.keys() } // MARK: - Internal @@ -75,7 +72,6 @@ public final class LDFileCache: KeyedValueCaching { dictionary[key] = data } } - dictionary.removeValue(forKey: Self.initializationKey) var data = try JSONEncoder().encode(dictionary) if let encryptionKey { data = try Util.encrypt(data, encryptionKey: encryptionKey, cacheKey: cacheKey) @@ -115,8 +111,6 @@ public final class LDFileCache: KeyedValueCaching { let fileName = Util.sha256hex(cacheKey) return dir.appendingPathComponent(fileName) } - - private static var initializationKey: String { "LDFileCache_initialized" } } extension LDFileCache: TypeIdentifying { }