You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1214 lines
44 KiB
1214 lines
44 KiB
//
|
|
// StoreCenter.swift
|
|
// LeStorage
|
|
//
|
|
// Created by Laurent Morvillier on 25/06/2024.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
|
|
public class StoreCenter {
|
|
|
|
/// The main instance
|
|
public static let main: StoreCenter = StoreCenter()
|
|
|
|
fileprivate lazy var _storeLibrary: StoreLibrary = { StoreLibrary(storeCenter: self) }()
|
|
|
|
/// The name of the directory to store the json files
|
|
let directoryName: String
|
|
|
|
/// Returns a default Store instance
|
|
public lazy var mainStore: Store = { Store(storeCenter: self) }()
|
|
|
|
/// A KeychainStore object used to store the user's token
|
|
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
|
|
|
|
/// A store for the Settings object
|
|
fileprivate var _settingsStorage: MicroStorage<Settings> = MicroStorage(
|
|
fileName: "settings.json")
|
|
|
|
/// The services performing the API calls
|
|
fileprivate var _services: Services?
|
|
|
|
/// The WebSocketManager that manages realtime synchronization
|
|
fileprivate var _webSocketManager: WebSocketManager?
|
|
|
|
/// The dictionary of registered api collections
|
|
fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:]
|
|
|
|
/// A collection of DataLog objects, used for the synchronization
|
|
fileprivate var _deleteLogs: StoredCollection<DataLog>? = nil
|
|
|
|
/// A synchronized collection of DataAccess
|
|
fileprivate(set) var dataAccessCollection: SyncedCollection<DataAccess>? = nil
|
|
|
|
/// A collection storing FailedAPICall objects
|
|
fileprivate var _failedAPICallsCollection: SyncedCollection<FailedAPICall>? = nil
|
|
|
|
/// A collection of Log objects
|
|
fileprivate var _logs: SyncedCollection<Log>? = nil
|
|
|
|
/// A list of username that cannot synchronize with the server
|
|
fileprivate var _blackListedUserName: [String] = []
|
|
|
|
/// The URL manager
|
|
fileprivate var _urlManager: URLManager? = nil
|
|
|
|
/// Gives the project name to retrieve classes from names
|
|
public var classProject: String? = nil
|
|
|
|
var useWebsockets: Bool = false
|
|
var useSynchronization: Bool = false
|
|
|
|
var synchronizesData: Bool = false
|
|
var wantsToSynchronize: Bool = false
|
|
|
|
init(directoryName: String? = nil) {
|
|
|
|
self.directoryName = directoryName ?? "storage"
|
|
|
|
self._createDirectory()
|
|
|
|
self._setupNotifications()
|
|
|
|
self.loadApiCallCollection(type: GetSyncData.self)
|
|
|
|
if let directoryName {
|
|
self._settingsStorage = MicroStorage(
|
|
fileName: "\(directoryName)/settings.json")
|
|
}
|
|
|
|
NetworkMonitor.shared.onConnectionEstablished = {
|
|
self._resumeApiCalls()
|
|
// self._configureWebSocket()
|
|
}
|
|
// Logger.log("device Id = \(self.deviceId())")
|
|
}
|
|
|
|
public func configureURLs(secureScheme: Bool, domain: String, webSockets: Bool = true, useSynchronization: Bool = false) {
|
|
|
|
self.useWebsockets = webSockets
|
|
self.useSynchronization = useSynchronization
|
|
self._deleteLogs = self.mainStore.registerCollection()
|
|
|
|
let urlManager: URLManager = URLManager(secureScheme: secureScheme, domain: domain)
|
|
self._urlManager = urlManager
|
|
self._services = Services(storeCenter: self, url: urlManager.api)
|
|
self.tokenKeychain = KeychainStore(serverId: urlManager.api)
|
|
|
|
if self.useSynchronization {
|
|
self.dataAccessCollection = self.mainStore.registerSynchronizedCollection()
|
|
}
|
|
|
|
Logger.log("Sync URL: \(urlManager.api)")
|
|
|
|
if self.userId != nil {
|
|
self._configureWebSocket()
|
|
}
|
|
}
|
|
|
|
fileprivate func _configureWebSocket() {
|
|
|
|
guard self.useWebsockets else {
|
|
return
|
|
}
|
|
|
|
self._webSocketManager?.disconnect()
|
|
self._webSocketManager = nil
|
|
|
|
guard let userId = self.userId else {
|
|
Logger.w("Tried to configure websocket but userId is nil")
|
|
return
|
|
}
|
|
guard let urlManager = self._urlManager else {
|
|
Logger.w("Tried to configure websocket no URL has been defined")
|
|
return
|
|
}
|
|
let url = urlManager.websocket(userId: userId)
|
|
self._webSocketManager = WebSocketManager(storeCenter: self, urlString: url)
|
|
Logger.log("websocket configured: \(url)")
|
|
}
|
|
|
|
public var hasWebSocketManager: Bool {
|
|
return self._webSocketManager != nil
|
|
}
|
|
public var websocketPingStatus: Bool {
|
|
return self._webSocketManager?.pingStatus ?? false
|
|
}
|
|
|
|
public var websocketFailure: Bool {
|
|
return self._webSocketManager?.failure ?? true
|
|
}
|
|
public var websocketError: Error? {
|
|
return self._webSocketManager?.error
|
|
}
|
|
public var websocketReconnectAttempts: Int {
|
|
return self._webSocketManager?.reconnectAttempts ?? 0
|
|
}
|
|
|
|
public var apiURL: String? {
|
|
return self._urlManager?.api
|
|
}
|
|
public var lastSyncDate: String {
|
|
return self._settingsStorage.item.lastSynchronization
|
|
}
|
|
|
|
/// Returns the service instance
|
|
public func service() throws -> Services {
|
|
if let service = self._services {
|
|
return service
|
|
} else {
|
|
throw StoreError.missingService
|
|
}
|
|
}
|
|
|
|
private func _setupNotifications() {
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(_willEnterForegroundNotification),
|
|
name: UIScene.willEnterForegroundNotification,
|
|
object: nil)
|
|
}
|
|
|
|
@objc fileprivate func _willEnterForegroundNotification() {
|
|
// Logger.log("_willEnterForegroundNotification")
|
|
self._launchSynchronization()
|
|
}
|
|
|
|
@objc fileprivate func _launchSynchronization() {
|
|
Task {
|
|
await self.synchronizeLastUpdates()
|
|
}
|
|
}
|
|
|
|
// MARK: - Store management
|
|
|
|
func requestStore(identifier: String) -> Store {
|
|
return self._storeLibrary.requestStore(identifier: identifier)
|
|
}
|
|
|
|
/// Returns the store corresponding to the provided id, and creates one if necessary, otherwise returns the main store
|
|
fileprivate func _requestStore(id: String?) -> Store {
|
|
if let storeId = id {
|
|
return self._storeLibrary.requestStore(identifier: storeId)
|
|
} else {
|
|
return self.mainStore
|
|
}
|
|
}
|
|
|
|
fileprivate func _store(id: String?) -> Store? {
|
|
if let storeId = id {
|
|
return self._storeLibrary[storeId]
|
|
} else {
|
|
return self.mainStore
|
|
}
|
|
}
|
|
|
|
public func store(identifier: String) throws -> Store {
|
|
if let store = self._storeLibrary[identifier] {
|
|
return store
|
|
}
|
|
throw StoreError.storeNotRegistered(id: identifier)
|
|
}
|
|
|
|
/// Deletes the directory using its identifier
|
|
/// - Parameters:
|
|
/// - identifier: The name of the directory
|
|
public func destroyStore(identifier: String) {
|
|
self._storeLibrary.destroyStore(identifier: identifier)
|
|
}
|
|
|
|
// MARK: - Settings
|
|
|
|
/// Sets the user info given a user
|
|
func userDidLogIn(user: UserBase, at date: Date) {
|
|
self._settingsStorage.update { settings in
|
|
settings.userId = user.id
|
|
settings.lastSynchronization = Date.microSecondFormatter.string(from: date)
|
|
self._configureWebSocket()
|
|
}
|
|
}
|
|
|
|
/// Returns the stored user Id
|
|
public var userId: String? {
|
|
return self._settingsStorage.item.userId
|
|
}
|
|
|
|
/// Returns the username
|
|
public var userName: String? {
|
|
return self._settingsStorage.item.username
|
|
}
|
|
|
|
/// Returns the stored token
|
|
public func token() throws -> String {
|
|
guard self.userName != nil else { throw StoreError.missingUsername }
|
|
guard let tokenKeychain else { throw StoreError.missingKeychainStore }
|
|
return try tokenKeychain.getValue()
|
|
}
|
|
|
|
public func rawTokenShouldNotBeUsed() throws -> String? {
|
|
return try self.tokenKeychain?.getValue()
|
|
}
|
|
|
|
/// Disconnect the user from the storage and resets collection
|
|
public func disconnect() {
|
|
try? self.tokenKeychain?.deleteValue()
|
|
|
|
self.resetApiCalls()
|
|
self._failedAPICallsCollection?.reset()
|
|
|
|
self._storeLibrary.reset()
|
|
self.dataAccessCollection?.reset()
|
|
self._deleteLogs?.reset()
|
|
|
|
self._settingsStorage.update { settings in
|
|
settings.username = nil
|
|
settings.userId = nil
|
|
settings.lastSynchronization = Date.microSecondFormatter.string(from: Date())
|
|
|
|
self._webSocketManager = nil
|
|
}
|
|
|
|
}
|
|
|
|
/// Returns whether the system has a user token
|
|
public var isAuthenticated: Bool {
|
|
guard self.userName != nil else { return false }
|
|
do {
|
|
let _ = try self.token()
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Stores a token for a corresponding username
|
|
/// - Parameters:
|
|
/// - username: the key used to store the token
|
|
/// - token: the token to store
|
|
func storeToken(username: String, token: String) throws {
|
|
self._settingsStorage.item.username = username
|
|
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 {
|
|
|
|
do {
|
|
return try self.deviceKeychain.getValue()
|
|
} catch {
|
|
let deviceId: String =
|
|
UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
|
do {
|
|
try self.deviceKeychain.add(value: deviceId)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
return deviceId
|
|
}
|
|
}
|
|
|
|
// 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
|
|
public func loadApiCallCollection<T: SyncedStorable>(type: T.Type) {
|
|
if self._apiCallCollections[T.resourceName()] == nil {
|
|
let apiCallCollection = ApiCallCollection<T>(storeCenter: self)
|
|
self._apiCallCollections[T.resourceName()] = apiCallCollection
|
|
Task {
|
|
do {
|
|
try await apiCallCollection.loadFromFile()
|
|
// let count = await apiCallCollection.items.count
|
|
// Logger.log("collection \(T.resourceName()) loaded with \(count)")
|
|
await apiCallCollection.rescheduleApiCallsIfNecessary()
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the ApiCall collection using the resource name of the provided T type
|
|
func apiCallCollection<T: Storable>() throws -> ApiCallCollection<T> {
|
|
if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> {
|
|
return collection
|
|
}
|
|
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName())
|
|
}
|
|
|
|
/// Deletes an ApiCall, identifying it by dataId
|
|
/// - Parameters:
|
|
/// - type: the subsequent type of the ApiCall
|
|
/// - id: the id of the data stored inside the ApiCall
|
|
func deleteApiCallByDataId<T: SyncedStorable>(type: T.Type, id: String) async throws {
|
|
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
|
|
await apiCallCollection.deleteByDataId(id)
|
|
}
|
|
|
|
/// Deletes an ApiCall by its id
|
|
/// - Parameters:
|
|
/// - type: the subsequent type of the ApiCall
|
|
/// - id: the id of the ApiCall
|
|
func deleteApiCallById<T: SyncedStorable>(type: T.Type, id: String) async throws {
|
|
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
|
|
await apiCallCollection.deleteById(id)
|
|
}
|
|
|
|
/// Deletes an ApiCall by its id
|
|
/// - Parameters:
|
|
/// - id: the id of the ApiCall
|
|
/// - collectionName: the name of the collection of ApiCall
|
|
func deleteApiCallById(_ id: String, collectionName: String) async throws {
|
|
if let apiCallCollection = self._apiCallCollections[collectionName] {
|
|
await apiCallCollection.deleteById(id)
|
|
} else {
|
|
throw StoreError.collectionNotRegistered(type: collectionName)
|
|
}
|
|
}
|
|
|
|
/// Resets all the api call collections
|
|
public func resetApiCalls() {
|
|
Task {
|
|
for collection in self._apiCallCollections.values {
|
|
await collection.reset()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func resetLoggingCollections() {
|
|
|
|
Task {
|
|
do {
|
|
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()
|
|
let logApiCallCollection: ApiCallCollection<Log> = try self.apiCallCollection()
|
|
await logApiCallCollection.reset()
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resets the ApiCall whose type identifies with the provided collection
|
|
/// - Parameters:
|
|
/// - collection: The collection identifying the Storable type
|
|
public func resetApiCalls<T: SyncedStorable>(type: T.Type) {
|
|
do {
|
|
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
|
|
Task {
|
|
await apiCallCollection.reset()
|
|
}
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Api call rescheduling
|
|
|
|
/// Retry API calls immediately
|
|
fileprivate func _resumeApiCalls() {
|
|
guard self.forceNoSynchronization == false else {
|
|
return
|
|
}
|
|
// Logger.log("_resumeApiCalls")
|
|
Task {
|
|
for collection in self._apiCallCollections.values {
|
|
await collection.resumeApiCalls()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reschedule an ApiCall by id
|
|
func rescheduleApiCalls<T: SyncedStorable>(type: T.Type) async throws {
|
|
guard self.forceNoSynchronization == false else {
|
|
return
|
|
}
|
|
let collection: ApiCallCollection<T> = try self.apiCallCollection()
|
|
await collection.rescheduleApiCallsIfNecessary()
|
|
}
|
|
|
|
/// Executes an ApiCall
|
|
// fileprivate func _executeApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>)
|
|
// async throws -> V
|
|
// {
|
|
// return try await self.service().runApiCall(apiCall)
|
|
// }
|
|
|
|
/// Executes an API call
|
|
func executeGet<T: SyncedStorable>(apiCall: ApiCall<T>) async throws -> Data {
|
|
return try await self.service().runGetApiCall(apiCall)
|
|
}
|
|
|
|
/// Executes an API call
|
|
public func execute<T: SyncedStorable>(apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
|
|
return try await self.service().runApiCalls(apiCalls)
|
|
}
|
|
|
|
// MARK: - Api calls
|
|
|
|
/// Returns whether the collection can synchronize
|
|
fileprivate func _canSynchronise() -> Bool {
|
|
return !self.forceNoSynchronization
|
|
&& self.isAuthenticated
|
|
&& self.userIsAllowed()
|
|
}
|
|
|
|
func sendOperationBatch<T: SyncedStorable>(_ batch: OperationBatch<T>) async throws {
|
|
guard self._canSynchronise() else {
|
|
return
|
|
}
|
|
return try await self.apiCallCollection().executeBatch(batch)
|
|
}
|
|
|
|
func singleBatchExecution<T: SyncedStorable>(_ batch: OperationBatch<T>) async throws {
|
|
return try await self.apiCallCollection().singleBatchExecution(batch)
|
|
}
|
|
|
|
/// Transmit the insertion request to the ApiCall collection
|
|
/// - Parameters:
|
|
/// - instance: an object to insert
|
|
// func sendInsertion<T: SyncedStorable>(_ instance: T) async throws -> T? {
|
|
// guard self._canSynchronise() else {
|
|
// return nil
|
|
// }
|
|
// return try await self.apiCallCollection().sendInsertion(instance)
|
|
// }
|
|
//
|
|
// /// Transmit the update request to the ApiCall collection
|
|
// /// - Parameters:
|
|
// /// - instance: an object to update
|
|
// func sendUpdate<T: SyncedStorable>(_ instance: T) async throws -> T? {
|
|
// guard self._canSynchronise() else {
|
|
// return nil
|
|
// }
|
|
// return try await self.apiCallCollection().sendUpdate(instance)
|
|
// }
|
|
//
|
|
// /// Transmit the deletion request to the ApiCall collection
|
|
// /// - Parameters:
|
|
// /// - instance: an object to delete
|
|
// func sendDeletion<T: SyncedStorable>(_ instance: T) async throws {
|
|
// guard self._canSynchronise() else {
|
|
// return
|
|
// }
|
|
// try await self.apiCallCollection().sendDeletion(instance)
|
|
// }
|
|
|
|
/// Retrieves all the items on the server
|
|
func getItems<T: SyncedStorable>(identifier: String? = nil) async throws -> [T] {
|
|
return try await self.service().get(identifier: identifier)
|
|
}
|
|
|
|
func itemsRetrieved<T: SyncedStorable>(_ results: [T], storeId: String?, clear: Bool) async {
|
|
await self._requestStore(id: storeId).loadCollectionItems(results, clear: clear)
|
|
}
|
|
|
|
/// Returns the names of all collections
|
|
public func apiCollectionDescriptors() async -> [(String, any Storable.Type)] {
|
|
var descriptors: [(String, any Storable.Type)] = []
|
|
for collection in self._apiCallCollections.values {
|
|
let name = await collection.resourceName()
|
|
let type = await collection.type()
|
|
descriptors.append((name, type))
|
|
}
|
|
return descriptors
|
|
}
|
|
|
|
// MARK: - Synchronization
|
|
|
|
/// Creates the ApiCallCollection to manage the calls to the API
|
|
fileprivate func _createSyncApiCallCollection() {
|
|
self.loadApiCallCollection(type: GetSyncData.self)
|
|
}
|
|
|
|
/// Loads all the data from the server for the users
|
|
public func initialSynchronization(clear: Bool) {
|
|
self.mainStore.loadCollectionsFromServer(clear: clear)
|
|
|
|
// request data that has been shared with the user
|
|
if self.useSynchronization {
|
|
Task {
|
|
do {
|
|
try await self.service().getUserDataAccessContent()
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// Basically asks the server for new content
|
|
@discardableResult public func synchronizeLastUpdates() async -> Error? {
|
|
|
|
Logger.log("synchronizeLastUpdates: self.synchronizesData: \(self.synchronizesData) / self.isAuthenticated = \(self.isAuthenticated) / self.useSynchronization = \(self.useSynchronization)")
|
|
guard self.isAuthenticated, self.useSynchronization else {
|
|
return nil
|
|
}
|
|
guard !self.synchronizesData else {
|
|
self.wantsToSynchronize = true
|
|
return nil
|
|
}
|
|
Logger.log(">>> synchronizeLastUpdates started...")
|
|
|
|
self.synchronizesData = true
|
|
self.wantsToSynchronize = false
|
|
|
|
let lastSync = self._settingsStorage.item.lastSynchronization
|
|
|
|
do {
|
|
let syncGetCollection: ApiCallCollection<GetSyncData> = try self.apiCallCollection()
|
|
if await syncGetCollection.hasPendingCalls() == false {
|
|
Logger.log("*** START sync: \(lastSync)")
|
|
let getSyncData = GetSyncData()
|
|
getSyncData.date = lastSync
|
|
try await syncGetCollection.sendGetRequest(instance: getSyncData)
|
|
}
|
|
} catch {
|
|
self.synchronizesData = false
|
|
self.log(message: "sync failed: \(error)")
|
|
Logger.error(error)
|
|
return error
|
|
}
|
|
self.synchronizesData = false
|
|
Logger.log(">>> synchronizeLastUpdates ended.")
|
|
|
|
return nil
|
|
}
|
|
|
|
@discardableResult func testSynchronizeOnceAsync() async throws -> Data {
|
|
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
|
|
return try 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
|
|
}
|
|
|
|
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
|
|
try await apiCallCollection.sendGetRequest(storeId: storeId, clear: clear)
|
|
}
|
|
|
|
func canPerformGet<T: SyncedStorable>(_ type: T.Type) -> Bool {
|
|
return T.tokenExemptedMethods().contains(where: { $0 == .get }) || self.isAuthenticated
|
|
}
|
|
|
|
/// Processes Data Access data
|
|
func userDataAccessRetrieved(_ data: Data) async {
|
|
do {
|
|
guard
|
|
let json = try JSONSerialization.jsonObject(with: data, options: [])
|
|
as? [String: Any]
|
|
else {
|
|
let string = String(data: data, encoding: .utf8) ?? "--"
|
|
Logger.w("data unrecognized: \(string)")
|
|
return
|
|
}
|
|
|
|
let array = try self.decodeDictionary(json)
|
|
await self._syncAddOrUpdate(array, shared: .shared)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
/// Processes the data coming from a sync request
|
|
@MainActor func synchronizeContent(_ syncData: SyncData) async {
|
|
|
|
await self._syncAddOrUpdate(syncData.updates)
|
|
await self._syncDelete(syncData.deletions)
|
|
await self._syncAddOrUpdate(syncData.shared, shared: .shared)
|
|
await self._syncAddOrUpdate(syncData.grants, shared: .granted)
|
|
await self.syncRevoke(syncData.revocations, parents: syncData.revocationParents)
|
|
// self._syncAddOrUpdate(syncData.relationshipSets)
|
|
// await self._syncDelete(syncData.relationshipRemovals)
|
|
await self._syncAddOrUpdate(syncData.sharedRelationshipSets, shared: .granted)
|
|
await self._syncRevoke(syncData.sharedRelationshipRemovals)
|
|
|
|
// Logger.log("sync content: updates = \(syncData.updates.count) / deletions = \(syncData.deletions.count), grants = \(syncData.grants.count)")
|
|
|
|
if let dateString = syncData.date {
|
|
Logger.log("Sets sync date = \(dateString)")
|
|
self._settingsStorage.update { settings in
|
|
settings.lastSynchronization = dateString
|
|
}
|
|
}
|
|
|
|
self.synchronizesData = false
|
|
if self.wantsToSynchronize {
|
|
await self.synchronizeLastUpdates()
|
|
}
|
|
|
|
// Logger.log(">>> SYNC ENDED")
|
|
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name.LeStorageDidSynchronize, object: self)
|
|
|
|
}
|
|
|
|
/// Processes data that should be inserted or updated inside the app
|
|
/// - Parameters:
|
|
/// - updateArrays: the server updates
|
|
/// - shared: indicates if the content should be flagged as shared
|
|
@MainActor
|
|
fileprivate func _syncAddOrUpdate(_ updateArrays: [SyncedStorableArray], shared: SharingStatus? = nil) async {
|
|
|
|
for updateArray in updateArrays {
|
|
await self._syncAddOrUpdate(updateArray, type: updateArray.type, shared: shared)
|
|
// for item in updateArray.items {
|
|
// let storeId: String? = item.getStoreId()
|
|
// await self.synchronizationAddOrUpdate(item, storeId: storeId, shared: shared)
|
|
// }
|
|
}
|
|
|
|
}
|
|
|
|
@MainActor
|
|
fileprivate func _syncAddOrUpdate<T: SyncedStorable>(_ updateArray: SyncedStorableArray, type: T.Type, shared: SharingStatus? = nil) async {
|
|
|
|
let itemsByStore = updateArray.items.group { $0.getStoreId() }
|
|
for (storeId, items) in itemsByStore {
|
|
let store = self._requestStore(id: storeId)
|
|
store.synchronizationAddOrUpdate(items as! [T], shared: shared)
|
|
}
|
|
}
|
|
|
|
/// Processes data that should be deleted inside the app
|
|
fileprivate func _syncDelete(_ deletionArrays: [ObjectIdentifierArray]) async {
|
|
for deletionArray in deletionArrays {
|
|
await self._syncDelete(deletionArray, type: deletionArray.type)
|
|
|
|
// for deletedObject in deletionArray.items {
|
|
// await self.synchronizationDelete(id: deletedObject.modelId, type: deletionArray.type, storeId: deletedObject.storeId)
|
|
// }
|
|
}
|
|
}
|
|
|
|
fileprivate func _syncDelete<T : SyncedStorable>(_ deletionArray: ObjectIdentifierArray, type: T.Type) async {
|
|
|
|
let itemsByStore = deletionArray.items.group { $0.storeId }
|
|
for (storeId, items) in itemsByStore {
|
|
if let store = self._store(id: storeId) {
|
|
await store.synchronizationDelete(items, type: T.self)
|
|
}
|
|
}
|
|
|
|
// for deletedObject in deletionArray.items {
|
|
//
|
|
// let itemsByStore = deletionArray.items.group { $0.storeId }
|
|
// for (storeId, items) in itemsByStore {
|
|
// let store = self._requestStore(id: storeId)
|
|
// await store.synchronizationDelete(items, type: T.self)
|
|
// }
|
|
//// await self.synchronizationDelete(id: deletedObject.modelId, type: deletionArray.type, storeId: deletedObject.storeId)
|
|
// }
|
|
}
|
|
|
|
|
|
/// Processes data that has been revoked
|
|
fileprivate func syncRevoke(_ revokedArrays: [ObjectIdentifierArray], parents: [[ObjectIdentifierArray]]) async {
|
|
|
|
await self._syncRevoke(revokedArrays)
|
|
for revokedArray in revokedArrays {
|
|
await self._syncDelete(revokedArray, type: revokedArray.type)
|
|
|
|
// for revoked in revokedArray.items {
|
|
// await self.synchronizationDelete(id: revoked.modelId, type: revokedArray.type, storeId: revoked.storeId) // or synchronizationRevoke ?
|
|
// }
|
|
}
|
|
|
|
for level in parents {
|
|
await self._syncRevoke(level)
|
|
}
|
|
}
|
|
|
|
fileprivate func _syncRevoke(_ revokeArrays: [ObjectIdentifierArray]) async {
|
|
for revokeArray in revokeArrays {
|
|
await self._syncRevoke(revokeArray: revokeArray)
|
|
// for revoked in revokeArray.items {
|
|
// await self.synchronizationRevoke(id: revoked.modelId, type: revokeArray.type, storeId: revoked.storeId)
|
|
// }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
fileprivate func _syncRevoke(revokeArray: ObjectIdentifierArray) async {
|
|
|
|
let itemsByStore = revokeArray.items.group { $0.storeId }
|
|
for (storeId, items) in itemsByStore {
|
|
let store = self._requestStore(id: storeId)
|
|
store.synchronizationRevoke(items, type: revokeArray.type)
|
|
}
|
|
|
|
// for revoked in revokeArray.items {
|
|
//
|
|
//
|
|
//
|
|
// }
|
|
|
|
}
|
|
|
|
/// Returns a Type object for a class name
|
|
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 {
|
|
throw LeStorageError.cantFindClassFromName(name: className)
|
|
}
|
|
} else {
|
|
throw LeStorageError.cantFindClassFromName(name: className)
|
|
}
|
|
|
|
}
|
|
|
|
/// Returns whether a data has already been deleted by, to avoid inserting it again
|
|
func hasAlreadyBeenDeleted<T: Storable>(_ instance: T) -> Bool {
|
|
guard let deleteLogs = self._deleteLogs else {
|
|
fatalError("missing delete logs collection")
|
|
}
|
|
return deleteLogs.contains(where: {
|
|
$0.dataId == instance.stringId && $0.operation == .delete
|
|
})
|
|
}
|
|
|
|
/// Adds or updates an instance into the store
|
|
// func synchronizationAddOrUpdate<T: SyncedStorable>(_ instance: T, storeId: String?, shared: SharingStatus?) async {
|
|
// let hasAlreadyBeenDeleted: Bool = self.hasAlreadyBeenDeleted(instance)
|
|
// if !hasAlreadyBeenDeleted {
|
|
// await self._requestStore(id: storeId).addOrUpdateIfNewer(instance, shared: shared)
|
|
// }
|
|
// }
|
|
|
|
/// Deletes an instance with the given parameters
|
|
// @MainActor
|
|
// func synchronizationDelete<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) {
|
|
// do {
|
|
// try self._store(id: storeId).deleteNoSyncNoCascade(type: type, id: id)
|
|
// } catch {
|
|
// Logger.error(error)
|
|
// }
|
|
// self.cleanupDataLog(dataId: id)
|
|
// }
|
|
|
|
/// Revokes a data that has been shared with the user
|
|
// @MainActor
|
|
// func synchronizationRevoke<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) {
|
|
//
|
|
// do {
|
|
// if let instance = self._instance(id: id, type: type, storeId: storeId) {
|
|
// if instance.sharing != nil && !self.isReferenced(instance: instance) {
|
|
// try self._store(id: storeId).deleteNoSyncNoCascade(type: type, id: id)
|
|
// }
|
|
// }
|
|
// } catch {
|
|
// Logger.error(error)
|
|
// }
|
|
// }
|
|
|
|
// fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) -> T? {
|
|
// let realId: T.ID = T.buildRealId(id: id)
|
|
// return self._store(id: storeId).findById(realId)
|
|
// }
|
|
|
|
/// Returns whether an instance has been shared with the user
|
|
// fileprivate func _instanceShared<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) -> Bool {
|
|
//// let realId: T.ID = T.buildRealId(id: id)
|
|
// let instance: T? = self._instance(id: id, type: type, storeId: storeId)
|
|
// return instance?.sharing != nil
|
|
// }
|
|
|
|
/// Deletes a data log by data id
|
|
func cleanupDataLog(dataId: String) {
|
|
guard let deleteLogs = self._deleteLogs else {
|
|
return
|
|
}
|
|
let logs = deleteLogs.filter { $0.dataId == dataId }
|
|
deleteLogs.delete(contentOfs: logs)
|
|
}
|
|
|
|
/// Creates a delete log for an instance
|
|
func createDeleteLog<T: Storable>(_ instance: T) {
|
|
let dataLog = DataLog(dataId: instance.stringId,
|
|
modelName: String(describing: T.self),
|
|
operation: .delete)
|
|
self._deleteLogs?.addOrUpdate(instance: dataLog)
|
|
}
|
|
|
|
/// Returns the appropriate store for a relationship
|
|
/// - Parameters:
|
|
/// - instance: some Storable instance
|
|
/// - relationship: the relationship
|
|
func relationshipStore<T: Storable>(instance: T, relationship: Relationship) -> Store? {
|
|
switch relationship.storeLookup {
|
|
case .main: return Store.main
|
|
case .child: return self._storeLibrary[instance.stringId]
|
|
case .same: return instance.store
|
|
}
|
|
}
|
|
|
|
/// Returns if an instance has at least one valid parent relationship by checking if the id of the parent exists
|
|
/// - Parameters:
|
|
/// - instance: some Storable instance
|
|
/// - relationship: the relationship
|
|
func hasParentReferences<T: Storable, S: Storable>(instance: T, relationshipType: S.Type, relationship: Relationship) -> Bool {
|
|
if let referenceId = instance[keyPath: relationship.keyPath] as? S.ID,
|
|
let store = self.relationshipStore(instance: instance, relationship: relationship) {
|
|
let instance: S? = store.findById(referenceId)
|
|
return instance != nil
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isReferenced<T: Storable>(instance: T) -> Bool {
|
|
|
|
for relationship in T.parentRelationships() {
|
|
if self.hasParentReferences(instance: instance, relationshipType: relationship.type, relationship: relationship) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
for relationship in T.childrenRelationships() {
|
|
if let store = self.relationshipStore(instance: instance, relationship: relationship) {
|
|
if store.isReferenced(collectionType: relationship.type, type: T.self, id: instance.stringId) {
|
|
return true
|
|
}
|
|
} else {
|
|
Logger.w("missing store for instance \(instance)")
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MARK: - Sync data conversion
|
|
|
|
func decodeObjectIdentifierDictionary(_ dictionary: [String: Any]) throws -> [ObjectIdentifierArray] {
|
|
|
|
var objectIdentifierArray: [ObjectIdentifierArray] = []
|
|
|
|
for (className, dataArray) in dictionary {
|
|
|
|
guard let array = dataArray as? [[String: Any]] else {
|
|
Logger.w("Invalid update data for \(className)")
|
|
continue
|
|
}
|
|
let type = try self.classFromName(className)
|
|
let decodedArray = try self._decodeArray(type: ObjectIdentifier.self, array: array)
|
|
objectIdentifierArray.append(ObjectIdentifierArray(type: type, items: decodedArray))
|
|
}
|
|
return objectIdentifierArray
|
|
}
|
|
|
|
func decodeDictionary(_ dictionary: [String: Any]) throws -> [SyncedStorableArray] {
|
|
|
|
var syncedStorableArray: [SyncedStorableArray] = []
|
|
|
|
for (className, dataArray) in dictionary {
|
|
|
|
guard let array = dataArray as? [[String: Any]] else {
|
|
Logger.w("Invalid update data for \(className)")
|
|
continue
|
|
}
|
|
// Logger.log(">>> UPDATE \(array.count) \(className)")
|
|
|
|
let type = try self.classFromName(className)
|
|
let decodedArray = try self._decodeArray(type: type, array: array)
|
|
syncedStorableArray.append(SyncedStorableArray(type: type, items: decodedArray))
|
|
}
|
|
return syncedStorableArray
|
|
}
|
|
|
|
fileprivate func _decodeArray<T: Decodable>(type: T.Type, array: [[String : Any]]) throws -> [T] {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: array, options: [])
|
|
return try JSON.decoder.decode([T].self, from: jsonData)
|
|
}
|
|
|
|
// MARK: - Miscellanous
|
|
|
|
/// Returns the count of api calls for a Type
|
|
public func apiCallCount<T: SyncedStorable>(type: T.Type) async -> Int {
|
|
do {
|
|
let collection: ApiCallCollection<T> = try self.apiCallCollection()
|
|
return await collection.items.count
|
|
} catch {
|
|
return -1
|
|
}
|
|
}
|
|
|
|
/// Resets all registered collection
|
|
// public func reset() {
|
|
// Store.main.reset()
|
|
// for store in self._stores.values {
|
|
// store.reset()
|
|
// }
|
|
// }
|
|
|
|
/// Returns whether any collection has pending API calls
|
|
public func hasPendingAPICalls() async -> Bool {
|
|
for collection in self._apiCallCollections.values {
|
|
if await collection.hasPendingCalls() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Returns the content of the api call file
|
|
public func apiCallsFileContent(resourceName: String) async -> String {
|
|
return await self._apiCallCollections[resourceName]?.contentOfFile() ?? ""
|
|
}
|
|
|
|
public func apiCalls<T: SyncedStorable>(type: T.Type) async -> [ApiCall<T>] {
|
|
if let apiCallCollection: ApiCallCollection<T> = try? self.apiCallCollection() {
|
|
return await apiCallCollection.apiCalls()
|
|
}
|
|
return []
|
|
}
|
|
|
|
/// This method triggers the framework to save and send failed api calls
|
|
public func logsFailedAPICalls() {
|
|
self._failedAPICallsCollection = self.mainStore.registerSynchronizedCollection(limit: 50)
|
|
}
|
|
|
|
/// If configured for, logs and send to the server a failed API call
|
|
/// Logs a failed API call that has failed at least 5 times
|
|
func logFailedAPICall(
|
|
_ apiCallId: String, request: URLRequest, collectionName: String, error: String
|
|
) {
|
|
|
|
guard let failedAPICallsCollection = self._failedAPICallsCollection,
|
|
let collection = self._apiCallCollections[collectionName],
|
|
collectionName != FailedAPICall.resourceName()
|
|
else {
|
|
return
|
|
}
|
|
|
|
Task {
|
|
if let apiCall = await collection.findCallById(apiCallId) {
|
|
|
|
if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId })
|
|
&& apiCall.attemptsCount > 6
|
|
{
|
|
|
|
do {
|
|
let authValue = request.allHTTPHeaderFields?["Authorization"]
|
|
let string = try apiCall.jsonString()
|
|
let failedAPICall = FailedAPICall(
|
|
callId: apiCall.id, type: collectionName, apiCall: string, error: error,
|
|
authentication: authValue)
|
|
|
|
DispatchQueue.main.async {
|
|
failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
|
|
}
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// Logs a failed Api call with its request and error message
|
|
func logFailedAPICall(request: URLRequest, error: String) {
|
|
|
|
guard let failedAPICallsCollection = self._failedAPICallsCollection,
|
|
let body: Data = request.httpBody,
|
|
let bodyString = String(data: body, encoding: .utf8),
|
|
let url = request.url?.absoluteString
|
|
else {
|
|
return
|
|
}
|
|
|
|
let authValue = request.allHTTPHeaderFields?["Authorization"]
|
|
let failedAPICall = FailedAPICall(
|
|
callId: request.hashValue.formatted(), type: url, apiCall: bodyString, error: error,
|
|
authentication: authValue)
|
|
failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
|
|
|
|
}
|
|
|
|
/// Adds a userName to the black list
|
|
/// Black listed username cannot send data to the server
|
|
/// - Parameters:
|
|
/// - collection: The collection identifying the Storable type
|
|
public func blackListUserName(_ userName: String) {
|
|
self._blackListedUserName.append(userName)
|
|
}
|
|
|
|
/// Returns whether the current userName is allowed to sync with the server
|
|
func userIsAllowed() -> Bool {
|
|
guard let userName else { return true }
|
|
return !self._blackListedUserName.contains(where: { $0 == userName })
|
|
}
|
|
|
|
// MARK: - Instant update
|
|
|
|
/// Updates a local object with a server instance
|
|
func updateLocalInstances<T: SyncedStorable>(_ results: [T]) {
|
|
for result in results {
|
|
if let syncedCollection: SyncedCollection<T> = self.collectionOfInstance(result) as? SyncedCollection<T> {
|
|
if syncedCollection.findById(result.id) != nil {
|
|
syncedCollection.updateFromServerInstance(result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the collection hosting an instance
|
|
func collectionOfInstance<T: Storable>(_ instance: T) -> (any SomeCollection)? {
|
|
do {
|
|
if let storeId = instance.getStoreId() {
|
|
let store = try self.store(identifier: storeId)
|
|
return try store.someCollection(type: T.self)
|
|
} else {
|
|
return try Store.main.someCollection(type: T.self)
|
|
}
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
return nil
|
|
|
|
}
|
|
|
|
/// Search inside the additional stores to find the collection hosting the instance
|
|
// func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> BaseCollection<T>? {
|
|
// for store in self._stores.values {
|
|
// let collection: BaseCollection<T>? = try? store.collection()
|
|
// if collection?.findById(instance.id) != nil {
|
|
// return collection
|
|
// }
|
|
// }
|
|
// return nil
|
|
// }
|
|
|
|
// MARK: - Data Access
|
|
|
|
/// Returns the list of users have access to a data given its id
|
|
public func authorizedUsers(for modelId: String) -> [String] {
|
|
guard let dataAccessCollection = self.dataAccessCollection else {
|
|
return []
|
|
}
|
|
if let dataAccess = dataAccessCollection.first(where: { $0.modelId == modelId }) {
|
|
return dataAccess.sharedWith
|
|
}
|
|
return []
|
|
}
|
|
|
|
public func setAuthorizedUsersAsync<T: SyncedStorable>(for instance: T, users: [String]) async throws {
|
|
|
|
guard let dataAccessCollection = self.dataAccessCollection else {
|
|
throw StoreError.synchronizationInactive
|
|
}
|
|
guard let userId = self.userId else {
|
|
throw LeStorageError.cantCreateDataAccessBecauseUserIdIsNil
|
|
}
|
|
|
|
if let dataAccess = dataAccessCollection.first(where: { $0.modelId == instance.stringId }) {
|
|
if users.isEmpty {
|
|
try await dataAccessCollection.deleteAsync(instance: dataAccess)
|
|
} else {
|
|
dataAccess.sharedWith.removeAll()
|
|
dataAccess.sharedWith = users
|
|
try await dataAccessCollection.addOrUpdateAsync(instance: dataAccess)
|
|
}
|
|
} else {
|
|
let dataAccess = DataAccess(owner: userId, sharedWith: users, modelName: String(describing: type(of: instance)), modelId: instance.stringId, storeId: instance.getStoreId())
|
|
try await dataAccessCollection.addOrUpdateAsync(instance: dataAccess)
|
|
}
|
|
|
|
}
|
|
|
|
/// Sets the the list of authorized users for an instance
|
|
public func setAuthorizedUsers<T: SyncedStorable>(for instance: T, users: [String]) throws {
|
|
Task {
|
|
do {
|
|
try await self.setAuthorizedUsersAsync(for: instance, users: users)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Logs
|
|
|
|
/// Returns the logs collection and instantiates it if necessary
|
|
fileprivate func _logsCollection() -> SyncedCollection<Log> {
|
|
if let logs = self._logs {
|
|
return logs
|
|
} else {
|
|
let logsCollection: SyncedCollection<Log> = self.mainStore.registerSynchronizedCollection(limit: 50)
|
|
self._logs = logsCollection
|
|
return logsCollection
|
|
}
|
|
}
|
|
|
|
/// Logs a message in the logs collection
|
|
public func log(message: String) {
|
|
DispatchQueue.main.async {
|
|
let log = Log(message: message, user: self.userId)
|
|
self._logsCollection().addOrUpdate(instance: log)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
}
|
|
|
|
class ObjectIdentifier: Codable {
|
|
var modelId: String
|
|
var storeId: String?
|
|
}
|
|
|