De-singletonize StoreCenter and enable testing for multiple instances

sync_v2
Laurent 6 months ago
parent c8f204462a
commit 0145072771
  1. 6
      LeStorage.xcodeproj/project.pbxproj
  2. 13
      LeStorage/ApiCallCollection.swift
  3. 29
      LeStorage/BaseCollection.swift
  4. 21
      LeStorage/Storable.swift
  5. 14
      LeStorage/Store.swift
  6. 101
      LeStorage/StoreCenter.swift
  7. 2
      LeStorage/SyncedCollection.swift
  8. 12
      LeStorage/Utils/ClassLoader.swift
  9. 9
      LeStorage/Utils/KeychainStore.swift
  10. 44
      LeStorage/Utils/MockKeychainStore.swift

@ -17,6 +17,7 @@
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; };
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */; };
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; };
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; };
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; };
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; };
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; };
@ -73,6 +74,7 @@
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = "<group>"; };
C471F2572DB10649006317F4 /* MockKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainStore.swift; sourceTree = "<group>"; };
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
@ -190,6 +192,7 @@
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */,
C467AAE22CD2466400D76CD2 /* Formatter.swift */,
C4A47D832B7B97F000ADC637 /* KeychainStore.swift */,
C471F2572DB10649006317F4 /* MockKeychainStore.swift */,
C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */,
C4FAE69B2CEB8E9500790446 /* URLManager.swift */,
@ -372,6 +375,7 @@
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */,
C4D4779D2CB923720077713D /* DataLog.swift in Sources */,
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */,
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */,
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */,
C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */,
);
@ -523,6 +527,7 @@
C425D4492B6D24E1002A7B48 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@ -556,6 +561,7 @@
C425D44A2B6D24E1002A7B48 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;

@ -75,7 +75,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Returns the file URL of the collection
fileprivate func _urlForJSONFile() throws -> URL {
return try ApiCall<T>.urlForJSONFile()
return try self.storeCenter.jsonFileURL(for: ApiCall<T>.self)
}
/// Decodes the json file into the items array
@ -98,14 +98,12 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
fileprivate func _write() {
let fileName = ApiCall<T>.fileName()
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
// Logger.log("Start write to \(fileName)...")
do {
let jsonString: String = try self.items.jsonString()
try T.writeToStorageDirectory(content: jsonString, fileName: fileName)
try self.storeCenter.write(content: jsonString, fileName: fileName)
} catch {
Logger.error(error)
}
// Logger.log("End write")
}
}
@ -377,6 +375,13 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
self._prepareCalls(batch: batch)
await self._batchExecution()
}
func executeSingleGet(instance: T) async where T : URLParameterConvertible {
let call = self._createCall(.get, instance: instance, option: .none)
call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter)
self._addCallToWaitingList(call)
await self._batchExecution()
}
fileprivate func _prepareCalls(batch: OperationBatch<T>) {
let transactionId = Store.randomId()

@ -64,7 +64,7 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) {
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) {
if indexed {
self._indexes = [:]
}
@ -72,10 +72,16 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
self.store = store
self.limit = limit
Task(priority: .high) {
await self.load()
if synchronousLoading {
self.loadFromFile()
Task {
await self.setAsLoaded()
}
} else {
Task(priority: .high) {
await self.load()
}
}
}
init(store: Store) {
@ -103,25 +109,23 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// Migrates if necessary and asynchronously decodes the json file
func load() async {
if !self.inMemory {
await self.loadFromFile()
self.loadFromFile()
} else {
await MainActor.run {
self.setAsLoaded()
}
await self.setAsLoaded()
}
}
/// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() async {
func loadFromFile() {
do {
try await self._decodeJSONFile()
try self._decodeJSONFile()
} catch {
Logger.error(error)
}
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() async throws {
fileprivate func _decodeJSONFile() throws {
let fileURL = try self.store.fileURL(type: T.self)
@ -130,9 +134,6 @@ public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
let decoded: [T] = try jsonString.decodeArray() ?? []
self.setItems(decoded)
}
await MainActor.run {
self.setAsLoaded()
}
}

@ -61,27 +61,6 @@ extension Storable {
return path
}
/// Returns the local URL of the storage directory
public static func storageDirectoryPath() throws -> URL {
return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
}
/// Writes some content to a file inside the storage directory
/// - content: the string to write inside the file
/// - fileName: the name of the file inside the storage directory
static func writeToStorageDirectory(content: String, fileName: String) throws {
var fileURL = try self.storageDirectoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
}
/// Returns the URL of the Storable json file
static func urlForJSONFile() throws -> URL {
var storageDirectory = try self.storageDirectoryPath()
storageDirectory.append(component: self.fileName())
return storageDirectory
}
static func buildRealId(id: String) -> ID {
switch ID.self {
case is String.Type:

@ -51,21 +51,21 @@ final public class Store {
/// The dictionary of registered collections
fileprivate var _collections: [String : any SomeCollection] = [:]
/// The name of the directory to store the json files
static let storageDirectory = "storage"
// /// The name of the directory to store the json files
// static let storageDirectory = "storage"
/// The store identifier, used to name the store directory, and to perform filtering requests to the server
public fileprivate(set) var identifier: String? = nil
public init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
self._createDirectory(directory: Store.storageDirectory)
}
public required init(storeCenter: StoreCenter, identifier: String) {
self.storeCenter = storeCenter
self.identifier = identifier
let directory = "\(Store.storageDirectory)/\(identifier)"
let directory = "\(storeCenter.directoryName)/\(identifier)"
self._createDirectory(directory: directory)
}
@ -103,13 +103,13 @@ final public class Store {
/// - Parameters:
/// - indexed: Creates an index to quickly access the data
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> SyncedCollection<T> {
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) -> SyncedCollection<T> {
if let collection: SyncedCollection<T> = try? self.syncedCollection() {
return collection
}
let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit, synchronousLoading: synchronousLoading)
self._collections[T.resourceName()] = collection
self.storeCenter.loadApiCallCollection(type: T.self)
return collection
@ -255,7 +255,7 @@ final public class Store {
/// Returns the directory URL of the store
fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName)
if let identifier {
url.append(component: identifier)
}

@ -13,13 +13,19 @@ public class StoreCenter {
/// The main instance
public static let main: StoreCenter = StoreCenter()
/// The name of the directory to store the json files
let directoryName: String
/// A dictionary of Stores associated to their id
fileprivate var _stores: [String: Store] = [:]
lazy var mainStore: Store = { Store(storeCenter: self) }()
/// A KeychainStore object used to store the user's token
var keychainStore: KeychainStore? = nil
var tokenKeychain: KeychainService? = nil
/// A KeychainStore object used to store the user's token
var deviceKeychain: KeychainService = KeychainStore(serverId: "lestorage.device")
/// Force the absence of synchronization
public var forceNoSynchronization: Bool = false
@ -55,10 +61,12 @@ public class StoreCenter {
/// The URL manager
fileprivate var _urlManager: URLManager? = nil
/// Memory only alternate device id for testing purpose
var alternateDeviceId: String? = nil
var classProject: String? = nil
init() {
init(directoryName: String? = nil) {
self.directoryName = directoryName ?? "storage"
self._createDirectory()
self._setupNotifications()
@ -71,17 +79,17 @@ public class StoreCenter {
// Logger.log("device Id = \(self.deviceId())")
}
public func configureURLs(secureScheme: Bool, domain: String) {
public func configureURLs(secureScheme: Bool, domain: String, webSockets: Bool = true) {
let urlManager: URLManager = URLManager(secureScheme: secureScheme, domain: domain)
self._urlManager = urlManager
self._services = Services(storeCenter: self, url: urlManager.api)
self.keychainStore = KeychainStore(serverId: urlManager.api)
self.tokenKeychain = KeychainStore(serverId: urlManager.api)
self._dataAccess = self.mainStore.registerSynchronizedCollection()
Logger.log("Sync URL: \(urlManager.api)")
if self.userId != nil {
if webSockets && self.userId != nil {
self._configureWebSocket()
}
}
@ -205,17 +213,17 @@ public class StoreCenter {
/// Returns the stored token
public func token() throws -> String {
guard self.userName != nil else { throw StoreError.missingUsername }
guard let keychainStore else { throw StoreError.missingKeychainStore }
return try keychainStore.getValue()
guard let tokenKeychain else { throw StoreError.missingKeychainStore }
return try tokenKeychain.getValue()
}
public func rawTokenShouldNotBeUsed() throws -> String? {
return try self.keychainStore?.getValue()
return try self.tokenKeychain?.getValue()
}
/// Disconnect the user from the storage and resets collection
public func disconnect() {
try? self.keychainStore?.deleteValue()
try? self.tokenKeychain?.deleteValue()
self.resetApiCalls()
self._failedAPICallsCollection?.reset()
@ -251,27 +259,23 @@ public class StoreCenter {
/// - token: the token to store
func storeToken(username: String, token: String) throws {
self._settingsStorage.item.username = username
guard let keychainStore else { throw StoreError.missingKeychainStore }
try keychainStore.deleteValue()
try keychainStore.add(username: username, value: token)
guard let tokenKeychain else { throw StoreError.missingKeychainStore }
try tokenKeychain.deleteValue()
try tokenKeychain.add(username: username, value: token)
}
/// Returns a generated device id
/// If created, stores it inside the keychain to get a consistent value even if the app is deleted
/// as UIDevice.current.identifierForVendor value changes when the app is deleted and installed again
func deviceId() -> String {
if let alternateDeviceId {
return alternateDeviceId
}
let keychainStore = KeychainStore(serverId: "lestorage.main")
do {
return try keychainStore.getValue()
return try self.deviceKeychain.getValue()
} catch {
let deviceId: String =
UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
do {
try keychainStore.add(value: deviceId)
try self.deviceKeychain.add(value: deviceId)
} catch {
Logger.error(error)
}
@ -279,6 +283,32 @@ public class StoreCenter {
}
}
// MARK: - File system
/// Creates the store directory
/// - Parameters:
/// - directory: the name of the directory
fileprivate func _createDirectory() {
FileManager.default.createDirectoryInDocuments(directoryName: self.directoryName)
}
public func directoryURL() throws -> URL {
return try FileUtils.pathForDirectoryInDocuments(directory: self.directoryName)
}
/// Returns the URL of the Storable json file
func jsonFileURL<T: Storable>(for type: T.Type) throws -> URL {
var storageDirectory = try self.directoryURL()
storageDirectory.append(component: T.fileName())
return storageDirectory
}
func write(content: String, fileName: String) throws {
var fileURL = try self.directoryURL()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
}
// MARK: - Api Calls management
/// Instantiates and loads an ApiCallCollection with the provided type
@ -350,8 +380,11 @@ public class StoreCenter {
Task {
do {
try FileManager.default.removeItem(at: Log.urlForJSONFile())
try FileManager.default.removeItem(at: FailedAPICall.urlForJSONFile())
let logURL = try self.jsonFileURL(for: Log.self)
try FileManager.default.removeItem(at: logURL)
let facURL = try self.jsonFileURL(for: FailedAPICall.self)
try FileManager.default.removeItem(at: facURL)
let facApiCallCollection: ApiCallCollection<FailedAPICall> = try self.apiCallCollection()
await facApiCallCollection.reset()
@ -530,6 +563,18 @@ public class StoreCenter {
}
func testSynchronizeOnceAsync() async throws {
guard self.isAuthenticated else {
throw StoreError.missingToken
}
let lastSync = self._settingsStorage.item.lastSynchronization
let syncGetCollection: ApiCallCollection<GetSyncData> = try self.apiCallCollection()
let getSyncData = GetSyncData()
getSyncData.date = lastSync
await syncGetCollection.executeSingleGet(instance: getSyncData)
}
func sendGetRequest<T: SyncedStorable>(_ type: T.Type, storeId: String?, clear: Bool) async throws {
guard self._canSynchronise(), self.canPerformGet(T.self) else {
return
@ -635,7 +680,7 @@ public class StoreCenter {
}
Logger.log(">>> UPDATE \(updateArray.count) \(className)")
let type = try StoreCenter.classFromName(className)
let type = try self.classFromName(className)
for updateItem in updateArray {
@ -723,8 +768,8 @@ public class StoreCenter {
}
/// Returns a Type object for a class name
static func classFromName(_ className: String) throws -> any SyncedStorable.Type {
if let type = ClassLoader.getClass(className) {
func classFromName(_ className: String) throws -> any SyncedStorable.Type {
if let type = ClassLoader.getClass(className, classProject: self.classProject) {
if let syncedType = type as? any SyncedStorable.Type {
return syncedType
} else {
@ -773,7 +818,7 @@ public class StoreCenter {
DispatchQueue.main.async {
do {
let type = try StoreCenter.classFromName(model)
let type = try self.classFromName(model)
try self._store(id: storeId).deleteNoSync(type: type, id: id)
} catch {
Logger.error(error)
@ -787,7 +832,7 @@ public class StoreCenter {
DispatchQueue.main.async {
do {
let type = try StoreCenter.classFromName(model)
let type = try self.classFromName(model)
if self._instanceShared(id: id, type: type) {
let count = self.mainStore.referenceCount(type: type, id: id)
if count == 0 {
@ -948,7 +993,7 @@ public class StoreCenter {
/// - Parameters:
/// - identifier: The name of the directory
public func destroyStore(identifier: String) {
let directory = "\(Store.storageDirectory)/\(identifier)"
let directory = "\(self.directoryName)/\(identifier)"
FileManager.default.deleteDirectoryInDocuments(directoryName: directory)
self._stores.removeValue(forKey: identifier)
}

@ -25,7 +25,7 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
if self.inMemory {
try await self.loadDataFromServerIfAllowed()
} else {
await self.loadFromFile()
self.loadFromFile()
}
} catch {
Logger.error(error)

@ -8,9 +8,9 @@
import Foundation
class ClassLoader {
static var classCache: [String: AnyClass] = [:]
static var classCache: [String : AnyClass] = [:]
static func getClass(_ className: String) -> AnyClass? {
static func getClass(_ className: String, classProject: String? = nil) -> AnyClass? {
if let cachedClass = classCache[className] {
return cachedClass
}
@ -23,6 +23,14 @@ class ClassLoader {
}
}
if let classProject {
let sanitizedBundleName = classProject.replacingOccurrences(of: " ", with: "_")
let fullName = "\(sanitizedBundleName).\(className)"
if let projectClass = _getClass(fullName) {
return projectClass
}
}
let leStorageClassName = "LeStorage.\(className)"
if let projectClass = _getClass(leStorageClassName) {
return projectClass

@ -24,7 +24,14 @@ enum KeychainError: Error {
}
}
class KeychainStore {
protocol KeychainService {
func add(username: String, value: String) throws
func add(value: String) throws
func getValue() throws -> String
func deleteValue() throws
}
class KeychainStore: KeychainService {
let serverId: String

@ -0,0 +1,44 @@
//
// MockKeychainStore.swift
// LeStorage
//
// Created by Laurent Morvillier on 17/04/2025.
//
import Foundation
class TokenStore: MicroStorable {
required init() {
}
var token: String?
}
class MockKeychainStore: MicroStorage<TokenStore>, KeychainService {
let key = "store"
func add(username: String, value: String) throws {
try self.add(value: value)
}
func add(value: String) throws {
self.update { tokenStore in
tokenStore.token = value
}
}
func getValue() throws -> String {
if let value = self.item.token {
return value
}
throw KeychainError.keychainItemNotFound(serverId: "mock")
}
func deleteValue() throws {
self.update { tokenStore in
tokenStore.token = nil
}
}
}
Loading…
Cancel
Save