diff --git a/Source/Shared/Configuration/DiskConfig.swift b/Source/Shared/Configuration/DiskConfig.swift index 3d0a01c5..48f00a41 100644 --- a/Source/Shared/Configuration/DiskConfig.swift +++ b/Source/Shared/Configuration/DiskConfig.swift @@ -6,6 +6,10 @@ public struct DiskConfig { /// Expiry date that will be applied by default for every added object /// if it's not overridden in the add(key: object: expiry: completion:) method public let expiry: Expiry + + /// ExpirationMode that will be applied for every added object + public let expirationMode: ExpirationMode + /// Maximum size of the disk cache storage (in bytes) public let maxSize: UInt /// A folder to store the disk cache contents. Defaults to a prefixed directory in Caches if nil @@ -15,11 +19,15 @@ public struct DiskConfig { /// Support only on iOS and tvOS. public let protectionType: FileProtectionType? - public init(name: String, expiry: Expiry = .never, + public init(name: String, + expiry: Expiry = .never, + expirationMode: ExpirationMode = .auto, maxSize: UInt = 0, directory: URL? = nil, protectionType: FileProtectionType? = nil) { self.name = name self.expiry = expiry + self.expirationMode = expirationMode + self.maxSize = maxSize self.directory = directory self.protectionType = protectionType diff --git a/Source/Shared/Configuration/MemoryConfig.swift b/Source/Shared/Configuration/MemoryConfig.swift index 898e6037..782c9ea5 100644 --- a/Source/Shared/Configuration/MemoryConfig.swift +++ b/Source/Shared/Configuration/MemoryConfig.swift @@ -1,20 +1,27 @@ import Foundation public struct MemoryConfig { - /// Expiry date that will be applied by default for every added object - /// if it's not overridden in the add(key: object: expiry: completion:) method - public let expiry: Expiry - /// The maximum number of objects in memory the cache should hold. - /// If 0, there is no count limit. The default value is 0. - public let countLimit: UInt - - /// The maximum total cost that the cache can hold before it starts evicting objects. - /// If 0, there is no total cost limit. The default value is 0 - public let totalCostLimit: UInt - - public init(expiry: Expiry = .never, countLimit: UInt = 0, totalCostLimit: UInt = 0) { - self.expiry = expiry - self.countLimit = countLimit - self.totalCostLimit = totalCostLimit - } + /// Expiry date that will be applied by default for every added object + /// if it's not overridden in the add(key: object: expiry: completion:) method + public let expiry: Expiry + + /// ExpirationMode that will be applied for every added object + public let expirationMode: ExpirationMode + + /// The maximum number of objects in memory the cache should hold. + /// If 0, there is no count limit. The default value is 0. + public let countLimit: UInt + + /// The maximum total cost that the cache can hold before it starts evicting objects. + /// If 0, there is no total cost limit. The default value is 0 + public let totalCostLimit: UInt + + public init(expiry: Expiry = .never, expirationMode: ExpirationMode = .auto, countLimit: UInt = 0, totalCostLimit: UInt = 0) { + self.expiry = expiry + self.expirationMode = expirationMode + + self.countLimit = countLimit + self.totalCostLimit = totalCostLimit + + } } diff --git a/Source/Shared/Storage/AsyncStorage.swift b/Source/Shared/Storage/AsyncStorage.swift index 032ea08a..17bc59da 100644 --- a/Source/Shared/Storage/AsyncStorage.swift +++ b/Source/Shared/Storage/AsyncStorage.swift @@ -127,3 +127,9 @@ public extension AsyncStorage { return storage } } + +public extension AsyncStorage { + func applyExpiratonMode(_ expirationMode: ExpirationMode) { + self.innerStorage.applyExpiratonMode(expirationMode) + } +} diff --git a/Source/Shared/Storage/DiskStorage.swift b/Source/Shared/Storage/DiskStorage.swift index ca4db9a1..95b81967 100644 --- a/Source/Shared/Storage/DiskStorage.swift +++ b/Source/Shared/Storage/DiskStorage.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif /// Save objects to file on disk final public class DiskStorage { @@ -17,6 +21,9 @@ final public class DiskStorage { private let transformer: Transformer private let hasher = Hasher.constantAccrossExecutions() + + private var didEnterBackgroundObserver: NSObjectProtocol? + // MARK: - Initialization public convenience init(config: DiskConfig, fileManager: FileManager = FileManager.default, transformer: Transformer) throws { @@ -54,6 +61,29 @@ final public class DiskStorage { self.fileManager = fileManager self.path = path self.transformer = transformer + applyExpiratonMode(self.config.expirationMode) + } + + public func applyExpiratonMode(_ expirationMode: ExpirationMode) { + if let didEnterBackgroundObserver = didEnterBackgroundObserver { + NotificationCenter.default.removeObserver(didEnterBackgroundObserver) + } + + if expirationMode == .auto { + didEnterBackgroundObserver = + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: nil) { [weak self] _ in + guard let `self` = self else { return } + try? self.removeExpiredObjects() + } + } + } + + deinit { + if let didEnterBackgroundObserver = didEnterBackgroundObserver { + NotificationCenter.default.removeObserver(didEnterBackgroundObserver) + } } } diff --git a/Source/Shared/Storage/HybridStorage.swift b/Source/Shared/Storage/HybridStorage.swift index 385980fe..d7db529f 100644 --- a/Source/Shared/Storage/HybridStorage.swift +++ b/Source/Shared/Storage/HybridStorage.swift @@ -180,3 +180,10 @@ public extension HybridStorage { return self.diskStorage.totalSize } } + +public extension HybridStorage { + func applyExpiratonMode(_ expirationMode: ExpirationMode) { + self.memoryStorage.applyExpiratonMode(expirationMode) + self.diskStorage.applyExpiratonMode(expirationMode) + } +} diff --git a/Source/Shared/Storage/MemoryStorage.swift b/Source/Shared/Storage/MemoryStorage.swift index 1adc46ff..b27fc82e 100644 --- a/Source/Shared/Storage/MemoryStorage.swift +++ b/Source/Shared/Storage/MemoryStorage.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif public class MemoryStorage: StorageAware { final class WrappedKey: NSObject { @@ -22,11 +26,39 @@ public class MemoryStorage: StorageAware { fileprivate var keys = Set() /// Configuration fileprivate let config: MemoryConfig + + /// The closure to be called when the key has been removed + public var onRemove: ((Key) -> Void)? + + public var didEnterBackgroundObserver: NSObjectProtocol? public init(config: MemoryConfig) { self.config = config self.cache.countLimit = Int(config.countLimit) self.cache.totalCostLimit = Int(config.totalCostLimit) + applyExpiratonMode(self.config.expirationMode) + } + + public func applyExpiratonMode(_ expirationMode: ExpirationMode) { + if let didEnterBackgroundObserver = didEnterBackgroundObserver { + NotificationCenter.default.removeObserver(didEnterBackgroundObserver) + } + if expirationMode == .auto { + didEnterBackgroundObserver = + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: nil) + { [weak self] _ in + guard let `self` = self else { return } + self.removeExpiredObjects() + } + } + } + + deinit { + if let didEnterBackgroundObserver = didEnterBackgroundObserver { + NotificationCenter.default.removeObserver(didEnterBackgroundObserver) + } } } @@ -41,7 +73,10 @@ extension MemoryStorage { public func setObject(_ object: Value, forKey key: Key, expiry: Expiry? = nil) { let capsule = MemoryCapsule(value: object, expiry: .date(expiry?.date ?? config.expiry.date)) - cache.setObject(capsule, forKey: WrappedKey(key)) + + /// MemoryLayout.size(ofValue:) return the contiguous memory footprint of the given instance , so cost is always MemoryCapsule size (8 bytes) + let cost = MemoryLayout.size(ofValue: capsule) + cache.setObject(capsule, forKey: WrappedKey(key), cost: cost) keys.insert(key) } @@ -66,6 +101,7 @@ extension MemoryStorage { public func removeObject(forKey key: Key) { cache.removeObject(forKey: WrappedKey(key)) keys.remove(key) + onRemove?(key) } public func entry(forKey key: Key) throws -> Entry { diff --git a/Source/Shared/Storage/Storage.swift b/Source/Shared/Storage/Storage.swift index e2d7c69c..4e101eca 100644 --- a/Source/Shared/Storage/Storage.swift +++ b/Source/Shared/Storage/Storage.swift @@ -123,3 +123,9 @@ public extension Storage { return self.hybridStorage.diskStorage.totalSize } } + +public extension Storage { + func applyExpiratonMode(_ expirationMode: ExpirationMode) { + self.hybridStorage.applyExpiratonMode(expirationMode) + } +} diff --git a/Source/Shared/Storage/SyncStorage.swift b/Source/Shared/Storage/SyncStorage.swift index 51d15f0e..38130501 100644 --- a/Source/Shared/Storage/SyncStorage.swift +++ b/Source/Shared/Storage/SyncStorage.swift @@ -73,3 +73,9 @@ public extension SyncStorage { return storage } } + +public extension SyncStorage { + func applyExpiratonMode(_ expirationMode: ExpirationMode) { + self.innerStorage.applyExpiratonMode(expirationMode) + } +} diff --git a/Tests/iOS/Tests/Storage/AsyncStorageTests.swift b/Tests/iOS/Tests/Storage/AsyncStorageTests.swift index 63158dc3..4c4c967a 100644 --- a/Tests/iOS/Tests/Storage/AsyncStorageTests.swift +++ b/Tests/iOS/Tests/Storage/AsyncStorageTests.swift @@ -62,4 +62,68 @@ final class AsyncStorageTests: XCTestCase { wait(for: [expectation], timeout: 1) } + + func testAutoClearAllExpiredObjectWhenApplicationEnterBackground() { + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + var key1Removed = false + var key2Removed = false + storage.innerStorage.memoryStorage.onRemove = { key in + key1Removed = true + key2Removed = true + XCTAssertTrue(key1Removed) + XCTAssertTrue(key2Removed) + } + + storage.innerStorage.diskStorage.onRemove = { path in + key1Removed = true + key2Removed = true + XCTAssertTrue(key1Removed) + XCTAssertTrue(key2Removed) + } + + storage.setObject(user, forKey: key1, expiry: expiry1) { _ in + + } + storage.setObject(user, forKey: key2, expiry: expiry2) { _ in + + } + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func testManualManageExpirationMode() { + storage.applyExpiratonMode(.manual) + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(60)) + let key1 = "item1" + let key2 = "item2" + + var key1Removed = false + var key2Removed = false + storage.innerStorage.memoryStorage.onRemove = { key in + key1Removed = true + key2Removed = true + XCTAssertFalse(key1Removed) + XCTAssertFalse(key2Removed) + } + + storage.innerStorage.diskStorage.onRemove = { path in + key1Removed = true + key2Removed = true + XCTAssertFalse(key1Removed) + XCTAssertFalse(key2Removed) + } + + storage.setObject(user, forKey: key1, expiry: expiry1) { _ in + + } + storage.setObject(user, forKey: key2, expiry: expiry2) { _ in + + } + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } } diff --git a/Tests/iOS/Tests/Storage/DiskStorageTests.swift b/Tests/iOS/Tests/Storage/DiskStorageTests.swift index a9505b13..a380807f 100644 --- a/Tests/iOS/Tests/Storage/DiskStorageTests.swift +++ b/Tests/iOS/Tests/Storage/DiskStorageTests.swift @@ -215,5 +215,37 @@ final class DiskStorageTests: XCTestCase { let filePath = "\(storage.path)/\(storage.makeFileName(for: key))" XCTAssertEqual(storage.makeFilePath(for: key), filePath) } + + func testAutoClearAllExpiredObjectWhenApplicationEnterBackground() { + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + let filePathForKey1 = storage.makeFilePath(for: key1) + storage.onRemove = { key in + XCTAssertTrue(key == filePathForKey1) + } + try? storage.setObject(testObject, forKey: key1, expiry: expiry1) + try? storage.setObject(testObject, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func testManualManageExpirationMode() { + storage.applyExpiratonMode(.manual) + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + var success = true + storage.onRemove = { key in + success = false + XCTAssertTrue(success) + } + try? storage.setObject(testObject, forKey: key1, expiry: expiry1) + try? storage.setObject(testObject, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } } diff --git a/Tests/iOS/Tests/Storage/HybridStorageTests.swift b/Tests/iOS/Tests/Storage/HybridStorageTests.swift index 0262fcb9..0b5e6df9 100644 --- a/Tests/iOS/Tests/Storage/HybridStorageTests.swift +++ b/Tests/iOS/Tests/Storage/HybridStorageTests.swift @@ -234,4 +234,59 @@ final class HybridStorageTests: XCTestCase { storage.removeAllKeyObservers() XCTAssertTrue(storage.keyObservations.isEmpty) } + + func testAutoClearAllExpiredObjectWhenApplicationEnterBackground() { + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + var key1Removed = false + var key2Removed = false + storage.memoryStorage.onRemove = { key in + key1Removed = true + key2Removed = true + XCTAssertTrue(key1Removed) + XCTAssertTrue(key2Removed) + } + + storage.diskStorage.onRemove = { path in + key1Removed = true + key2Removed = true + XCTAssertTrue(key1Removed) + XCTAssertTrue(key2Removed) + } + + try? storage.setObject(testObject, forKey: key1, expiry: expiry1) + try? storage.setObject(testObject, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func testManualManageExpirationMode() { + storage.applyExpiratonMode(.manual) + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + var key1Removed = false + var key2Removed = false + storage.memoryStorage.onRemove = { key in + key1Removed = true + key2Removed = true + XCTAssertFalse(key1Removed) + XCTAssertFalse(key2Removed) + } + + storage.diskStorage.onRemove = { path in + key1Removed = true + key2Removed = true + XCTAssertFalse(key1Removed) + XCTAssertFalse(key2Removed) + } + + try? storage.setObject(testObject, forKey: key1, expiry: expiry1) + try? storage.setObject(testObject, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } } diff --git a/Tests/iOS/Tests/Storage/MemoryStorageTests.swift b/Tests/iOS/Tests/Storage/MemoryStorageTests.swift index 3b084d94..81f8dc3e 100644 --- a/Tests/iOS/Tests/Storage/MemoryStorageTests.swift +++ b/Tests/iOS/Tests/Storage/MemoryStorageTests.swift @@ -5,7 +5,9 @@ final class MemoryStorageTests: XCTestCase { private let key = "youknownothing" private let testObject = User(firstName: "John", lastName: "Snow") private var storage: MemoryStorage! - private let config = MemoryConfig(expiry: .never, countLimit: 10, totalCostLimit: 10) + + /// 16 bytes, can contain 2 objects + private let config = MemoryConfig(expiry: .never, countLimit: 10, totalCostLimit: 16) override func setUp() { super.setUp() @@ -104,13 +106,68 @@ final class MemoryStorageTests: XCTestCase { let expiry2: Expiry = .date(Date().addingTimeInterval(10)) let key1 = "item1" let key2 = "item2" + storage.onRemove = { key in + XCTAssertTrue(key == key1) + } storage.setObject(testObject, forKey: key1, expiry: expiry1) storage.setObject(testObject, forKey: key2, expiry: expiry2) storage.removeExpiredObjects() + let object1 = try? storage.object(forKey: key1) let object2 = try! storage.object(forKey: key2) XCTAssertNil(object1) XCTAssertNotNil(object2) } + + + func testAutoClearAllExpiredObjectWhenApplicationEnterBackground() { + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + storage.onRemove = { key in + XCTAssertTrue(key == key1) + } + storage.setObject(testObject, forKey: key1, expiry: expiry1) + storage.setObject(testObject, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func testManualManageExpirationMode() { + storage.applyExpiratonMode(.manual) + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + var success = true + storage.onRemove = { key in + success = false + XCTAssertTrue(success) + } + storage.setObject(testObject, forKey: key1, expiry: expiry1) + storage.setObject(testObject, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + ///we have set max cost 16 bytes, so the first object is released + func testCost() { + let key1 = "item1" + let key2 = "item2" + let key3 = "item3" + storage.setObject(testObject, forKey: key1) + storage.setObject(testObject, forKey: key2) + storage.setObject(testObject, forKey: key3) + + let object1 = try? storage.object(forKey: key1) + let object2 = try! storage.object(forKey: key2) + let object3 = try! storage.object(forKey: key2) + XCTAssertNotNil(object2) + XCTAssertNotNil(object3) + XCTAssertNil(object1) + } + + } diff --git a/Tests/iOS/Tests/Storage/StorageTests.swift b/Tests/iOS/Tests/Storage/StorageTests.swift index 80f3f18e..3b23950d 100644 --- a/Tests/iOS/Tests/Storage/StorageTests.swift +++ b/Tests/iOS/Tests/Storage/StorageTests.swift @@ -250,6 +250,36 @@ final class StorageTests: XCTestCase { XCTAssertTrue(changes1.isEmpty) XCTAssertTrue(changes2.isEmpty) } + + func testAutoClearAllExpiredObjectWhenApplicationEnterBackground() { + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + var key1Removed = false + var key2Removed = false + + try? storage.setObject(user, forKey: key1, expiry: expiry1) + try? storage.setObject(user, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func testManualManageExpirationMode() { + storage.applyExpiratonMode(.manual) + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(60)) + let key1 = "item1" + let key2 = "item2" + + var key1Removed = false + var key2Removed = false + try? storage.setObject(user, forKey: key1, expiry: expiry1) + try? storage.setObject(user, forKey: key2, expiry: expiry2) + + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } } private class ObserverMock {} diff --git a/Tests/iOS/Tests/Storage/SyncStorageTests.swift b/Tests/iOS/Tests/Storage/SyncStorageTests.swift index 4c055b33..0e2fb7ef 100644 --- a/Tests/iOS/Tests/Storage/SyncStorageTests.swift +++ b/Tests/iOS/Tests/Storage/SyncStorageTests.swift @@ -44,4 +44,61 @@ final class SyncStorageTests: XCTestCase { XCTAssertFalse(try intStorage.existsObject(forKey: "key-99")) } } + + func testAutoClearAllExpiredObjectWhenApplicationEnterBackground() { + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(10)) + let key1 = "item1" + let key2 = "item2" + var key1Removed = false + var key2Removed = false + storage.innerStorage.memoryStorage.onRemove = { key in + key1Removed = true + key2Removed = true + XCTAssertTrue(key1Removed) + XCTAssertTrue(key2Removed) + } + + storage.innerStorage.diskStorage.onRemove = { path in + key1Removed = true + key2Removed = true + XCTAssertTrue(key1Removed) + XCTAssertTrue(key2Removed) + } + + try? storage.setObject(user, forKey: key1, expiry: expiry1) + try? storage.setObject(user, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func testManualManageExpirationMode() { + storage.applyExpiratonMode(.manual) + let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) + let expiry2: Expiry = .date(Date().addingTimeInterval(60)) + let key1 = "item1" + let key2 = "item2" + + var key1Removed = false + var key2Removed = false + storage.innerStorage.memoryStorage.onRemove = { key in + key1Removed = true + key2Removed = true + XCTAssertFalse(key1Removed) + XCTAssertFalse(key2Removed) + } + + storage.innerStorage.diskStorage.onRemove = { path in + key1Removed = true + key2Removed = true + XCTAssertFalse(key1Removed) + XCTAssertFalse(key2Removed) + } + + try? storage.setObject(user, forKey: key1, expiry: expiry1) + try? storage.setObject(user, forKey: key2, expiry: expiry2) + ///Device enters background + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + }