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 */, diff --git a/LaunchDarkly/GeneratedCode/mocks.generated.swift b/LaunchDarkly/GeneratedCode/mocks.generated.swift index 4d9d2294..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)? @@ -353,17 +362,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/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 91961269..56cd0268 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -752,10 +752,12 @@ 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, 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, keysToMigrate: keys, from: LDConfig.Defaults.cacheFactory) + cacheConverter.convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts) var mobileKeys = config.getSecondaryMobileKeys() var internalCount = 0 let completionCheck = { diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index 99da1611..a44a3d21 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 cacheFactory: CacheFactory = { 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 cacheFactory: CacheFactory = Defaults.cacheFactory + /// LaunchDarkly defined minima for selected configurable items public let minima: Minima diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/CacheConverter.swift index 67365cb6..a1ff3690 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, keysToMigrate: [MobileKey], from oldCache: @escaping LDConfig.CacheFactory) func convertCacheData(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int) } @@ -40,6 +41,20 @@ final class CacheConverter: CacheConverting { init() { } + 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.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) + } + } + } + } + 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 } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift index a88b78c5..c9ac4e3d 100644 --- a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/KeyedValueCache.swift @@ -1,25 +1,14 @@ import Foundation - +import OSLog // sourcery: autoMockable -protocol KeyedValueCaching { +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] } -extension UserDefaults: KeyedValueCaching { - func set(_ value: Data, forKey: String) { - set(value as Any?, forKey: forKey) - } - - func removeAll() { - dictionaryRepresentation().keys.forEach { removeObject(forKey: $0) } - } - - func keys() -> [String] { - dictionaryRepresentation().keys.map { String($0) } - } +public extension LDConfig { + typealias CacheFactory = (_ cacheKey: String?, _ logger: OSLog) -> KeyedValueCaching } diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift new file mode 100644 index 00000000..a892ee4b --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDFileCache.swift @@ -0,0 +1,125 @@ +import Foundation +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 fileIO = fileQueue.debouncer() + private let logger: OSLog + + public static func factory(encryptionKey: String? = nil) -> LDConfig.CacheFactory { + return { cacheKey, logger in + 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) + cache.deserializeFromFile() + instances[cacheKey] = cache + 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 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: KeyedValueCaching, encryptionKey: String?, logger: OSLog) { + self.cacheKey = cacheKey + self.inMemoryCache = inMemoryCache + self.encryptionKey = encryptionKey + self.logger = logger + } + + func scheduleSerialization() { + fileIO.debounce(interval: Constants.writeToFileDelay) { [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 + } + } + 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 { + os_log("%s failed writing cache to file. Error: %s", + log: logger, type: .debug, typeName, String(describing: error)) + } + } + + func deserializeFromFile() { + do { + let url = try pathToFile() + 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) + } + } 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) + let fileName = Util.sha256hex(cacheKey) + return dir.appendingPathComponent(fileName) + } +} + +extension LDFileCache: TypeIdentifying { } + +extension LDFileCache { + enum Error: Swift.Error { + case cannotAccessLibraryDirectory + } + enum Constants { + static var writeToFileDelay: DispatchTimeInterval { .milliseconds(300) } + } +} diff --git a/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift new file mode 100644 index 00000000..198f857f --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ServiceObjects/Cache/LDInMemoryCache.swift @@ -0,0 +1,52 @@ +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 func factory() -> LDConfig.CacheFactory { + 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 + 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 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) + } +} 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 66015289..25d4b293 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 cacheFactory: LDConfig.CacheFactory - init(logger: OSLog) { + init(logger: OSLog, cacheFactory: @escaping LDConfig.CacheFactory) { self.logger = logger + self.cacheFactory = cacheFactory } func makeKeyedValueCache(cacheKey: String?) -> KeyedValueCaching { - UserDefaults(suiteName: cacheKey)! + cacheFactory(cacheKey, logger) } func makeFeatureFlagCache(mobileKey: MobileKey, maxCachedContexts: Int) -> FeatureFlagCaching { diff --git a/LaunchDarkly/LaunchDarkly/Util.swift b/LaunchDarkly/LaunchDarkly/Util.swift index aa7deeee..ffbbc701 100644 --- a/LaunchDarkly/LaunchDarkly/Util.swift +++ b/LaunchDarkly/LaunchDarkly/Util.swift @@ -1,7 +1,13 @@ import CommonCrypto 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._-") @@ -9,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)) @@ -17,6 +27,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.. 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/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) + } } diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/FeatureFlagCacheSpec.swift index d76417af..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), 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), 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), 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 new file mode 100644 index 00000000..268df509 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/Cache/KeyedValueCachingSpec.swift @@ -0,0 +1,160 @@ +import Foundation +import XCTest + +@testable import LaunchDarkly + +final class UserDefaultsCachingSpec: KeyedValueCachingBaseSpec { + override func makeSut(_ key: String) -> KeyedValueCaching { + return LDConfig.Defaults.cacheFactory(key, .disabled) + } +} + +final class LDInMemoryCacheSpec: KeyedValueCachingBaseSpec { + override func makeSut(_ key: String) -> KeyedValueCaching { + return LDInMemoryCache.factory()(key, .disabled) + } +} + +final class LDFileCacheSpec: KeyedValueCachingBaseSpec { + + override func makeSut(_ key: String) -> KeyedValueCaching { + return LDFileCache.factory(encryptionKey: "test_secret")(key, .disabled) + } + + 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) + sut.set(Data(), forKey: "key") + let url = try sut.pathToFile() + try Data("corrupt".utf8).write(to: url, options: .atomic) + sut.deserializeFromFile() + XCTAssertEqual(sut.keys(), ["key"]) + } + + 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), + "%^&*() !@#": try NSKeyedArchiver.archivedData(withRootObject: ["key": "value"], requiringSecureCoding: true), + ] + 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) + } +} + +// 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 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 testKeys() 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)) + } + } + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift b/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift index 6f3c0493..b989ca90 100644 --- a/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/UtilSpec.swift @@ -18,4 +18,51 @@ final class UtilSpec: XCTestCase { let output = Util.sha256(input).base64UrlEncodedString 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") + 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) + } }