diff --git a/kDriveCore/DI/FactoryService.swift b/kDriveCore/DI/FactoryService.swift index cf10e9340..6663b920d 100644 --- a/kDriveCore/DI/FactoryService.swift +++ b/kDriveCore/DI/FactoryService.swift @@ -158,7 +158,8 @@ public enum FactoryService { (loggerFactory, "BGTaskScheduling"), (loggerFactory, "PhotoLibraryUploader"), (loggerFactory, "AppDelegate"), - (loggerFactory, "FileProvider") + (loggerFactory, "FileProvider"), + (loggerFactory, "DriveInfosManager") ] return services } else { diff --git a/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager+FileProvider.swift b/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager+FileProvider.swift new file mode 100644 index 000000000..a97febd5d --- /dev/null +++ b/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager+FileProvider.swift @@ -0,0 +1,218 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import FileProvider +import Foundation +import InfomaniakConcurrency +import InfomaniakCore + +public extension DriveInfosManager { + private typealias FilteredDomain = (new: NSFileProviderDomain, existing: NSFileProviderDomain?) + + internal func initFileProviderDomains(drives: [Drive], user: InfomaniakCore.UserProfile) { + // Clean file provider storage if needed + if UserDefaults.shared.fpStorageVersion < currentFpStorageVersion { + do { + let fileURLs = try FileManager.default.contentsOfDirectory( + at: NSFileProviderManager.default.documentStorageURL, + includingPropertiesForKeys: nil + ) + for url in fileURLs { + try FileManager.default.removeItem(at: url) + } + UserDefaults.shared.fpStorageVersion = currentFpStorageVersion + } catch { + Log.driveInfosManager("FileManager issue :\(error)", level: .error) + } + } + + updateFileManagerDomains(drives: drives, user: user) + } + + internal func deleteFileProviderDomains(for userId: Int) { + NSFileProviderManager.getDomainsWithCompletionHandler { allDomains, error in + if let error { + Log.driveInfosManager("Error while getting domains: \(error)", level: .error) + } + + let domainsForCurrentUser = allDomains.filter { $0.identifier.rawValue.hasSuffix("_\(userId)") } + for domain in domainsForCurrentUser { + NSFileProviderManager.remove(domain) { error in + guard let error else { + return + } + Log.driveInfosManager("Error while removing domain \(domain.displayName): \(error)", level: .error) + } + } + } + } + + func deleteAllFileProviderDomains() { + NSFileProviderManager.removeAllDomains { error in + guard let error else { + Log.driveInfosManager("Did remove all domains") + return + } + Log.driveInfosManager("Error while removing domains: \(error)", level: .error) + } + } + + func getFileProviderManager(for drive: Drive, completion: @escaping (NSFileProviderManager) -> Void) { + getFileProviderManager(for: drive.objectId, completion: completion) + } + + func getFileProviderManager(driveId: Int, userId: Int, completion: @escaping (NSFileProviderManager) -> Void) { + let objectId = DriveInfosManager.getObjectId(driveId: driveId, userId: userId) + getFileProviderManager(for: objectId, completion: completion) + } + + func getFileProviderManager(for driveId: String, completion: @escaping (NSFileProviderManager) -> Void) { + getFileProviderDomain(for: driveId) { domain in + if let domain { + completion(NSFileProviderManager(for: domain) ?? .default) + } else { + completion(.default) + } + } + } + + private func getFileProviderDomain(for driveId: String, completion: @escaping (NSFileProviderDomain?) -> Void) { + NSFileProviderManager.getDomainsWithCompletionHandler { domains, error in + if let error { + Log.driveInfosManager("Error while getting domains: \(error)", level: .error) + completion(nil) + } else { + completion(domains.first { $0.identifier.rawValue == driveId }) + } + } + } + + // MARK: Update FileManager + + /// Diffing __NSFileProviderDomain__ for drives of a specified user, and propagate changes to the __NSFileProviderManager__ + private func updateFileManagerDomains(drives: [Drive], user: InfomaniakCore.UserProfile) { + let expiringActivity = ExpiringActivity(id: "\(#function)_\(UUID().uuidString)", delegate: nil) + expiringActivity.start() + Task { + let updatedDomains = drives.map { + NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier($0.objectId), + displayName: "\($0.name) (\(user.email))", + pathRelativeToDocumentStorage: "\($0.objectId)" + ) + } + + Log.driveInfosManager("Updated domains \(updatedDomains.count) for user :\(user.displayName) \(user.id)") + do { + try await updateDomainsIfNecessary(updatedDomains: updatedDomains, userId: user.id) + try await deleteDomainsIfNecessary(updatedDomains: updatedDomains, userId: user.id) + } catch { + Log.driveInfosManager("Error while updating file provider domains: \(error)", level: .error) + } + + expiringActivity.endAll() + } + } + + /// Insert or update Domains if necessary + private func updateDomainsIfNecessary(updatedDomains: [NSFileProviderDomain], userId: Int) async throws { + let existingDomainsForCurrentUser = try await existingDomains(for: userId) + + let updatedDomainsForCurrentUser: [FilteredDomain] = updatedDomains.map { newDomain in + let existingDomain = existingDomainsForCurrentUser.first { $0.identifier == newDomain.identifier } + return (newDomain, existingDomain) + } + + try await updatedDomainsForCurrentUser.concurrentForEach(customConcurrency: 1) { domain in + // Simply add domain if new + let newDomain = domain.new + guard let existingDomain = domain.existing else { + Log.driveInfosManager("Inserting new domain:\(newDomain.identifier)") + try await NSFileProviderManager.add(newDomain) + self.signalChanges(for: newDomain) + return + } + + // Update existing accounts if necessary + if existingDomain.displayName != newDomain.displayName { + Log.driveInfosManager("Updating domain:\(newDomain.identifier)") + try await NSFileProviderManager.remove(existingDomain) + try await NSFileProviderManager.add(newDomain) + self.signalChanges(for: newDomain) + } + } + } + + /// Delete Domains if necessary + private func deleteDomainsIfNecessary(updatedDomains: [NSFileProviderDomain], userId: Int) async throws { + // We need to fetch a fresh copy of the domains after the update + let existingDomainsForCurrentUser = try await existingDomains(for: userId) + + // Remove domains no longer present for current user + let removedDomainsForCurrentUser = updatedDomains.filter { updatedDomain in + guard existingDomainsForCurrentUser.contains(where: { $0.identifier == updatedDomain.identifier }) else { + return true + } + + return false + } + + try await removedDomainsForCurrentUser.concurrentForEach(customConcurrency: 1) { oldDomain in + Log.driveInfosManager("Removing domain:\(oldDomain.identifier)") + try await NSFileProviderManager.remove(oldDomain) + } + } + + /// Fetch a fresh list of registered domains for a specified user + private func existingDomains(for userId: Int) async throws -> [NSFileProviderDomain] { + let allDomains = try await NSFileProviderManager.domains() + let existingDomains = allDomains.filter { $0.identifier.rawValue.hasSuffix("_\(userId)") } + return existingDomains + } + + // MARK: Signal FileManager + + /// Signal changes on this Drive to the File Provider Extension + private func signalChanges(for domain: NSFileProviderDomain) { + guard let driveId = domain.driveId, let userId = domain.userId else { + Log.driveInfosManager( + "Unable to read: driveId:\(String(describing: domain.driveId)) userId:\(String(describing: domain.userId))", + level: .error + ) + return + } + + DriveInfosManager.instance.getFileProviderManager(driveId: driveId, userId: userId) { manager in + manager.signalEnumerator(for: .workingSet) { error in + guard let error else { + Log.driveInfosManager("did signal .workingSet") + return + } + + Log.driveInfosManager("failed to signal .workingSet \(error)", level: .error) + } + manager.signalEnumerator(for: .rootContainer) { error in + guard let error else { + Log.driveInfosManager("did signal .rootContainer") + return + } + Log.driveInfosManager("failed to signal .rootContainer \(error)", level: .error) + } + } + } +} diff --git a/kDriveCore/Data/Cache/DriveInfosManager.swift b/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager.swift similarity index 61% rename from kDriveCore/Data/Cache/DriveInfosManager.swift rename to kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager.swift index e55a32249..60984ed0d 100644 --- a/kDriveCore/Data/Cache/DriveInfosManager.swift +++ b/kDriveCore/Data/Cache/DriveInfosManager/DriveInfosManager.swift @@ -16,21 +16,23 @@ along with this program. If not, see . */ -import CocoaLumberjackSwift -import FileProvider import Foundation import InfomaniakCore import Realm import RealmSwift import Sentry -public class DriveInfosManager { - public static let instance = DriveInfosManager() +public final class DriveInfosManager { + private static let dbName = "DrivesInfos.realm" + private static let currentDbVersion: UInt64 = 9 - private let currentFpStorageVersion = 1 + + let currentFpStorageVersion = 1 + public let realmConfiguration: Realm.Configuration - private let dbName = "DrivesInfos.realm" - private var fileProviderManagers: [String: NSFileProviderManager] = [:] + + // TODO: use DI + public static let instance = DriveInfosManager() private class func removeDanglingObjects(ofType type: RLMObjectBase.Type, migration: Migration, ids: Set) { migration.enumerateObjects(ofType: type.className()) { oldObject, newObject in @@ -43,7 +45,7 @@ public class DriveInfosManager { private init() { realmConfiguration = Realm.Configuration( - fileURL: DriveFileManager.constants.rootDocumentsURL.appendingPathComponent(dbName), + fileURL: DriveFileManager.constants.rootDocumentsURL.appendingPathComponent(Self.dbName), schemaVersion: DriveInfosManager.currentDbVersion, migrationBlock: { migration, oldSchemaVersion in if oldSchemaVersion < DriveInfosManager.currentDbVersion { @@ -116,125 +118,6 @@ public class DriveInfosManager { drive.sharedWithMe = sharedWithMe } - private func initFileProviderDomains(drives: [Drive], user: InfomaniakCore.UserProfile) { - // Clean file provider storage if needed - if UserDefaults.shared.fpStorageVersion < currentFpStorageVersion { - do { - let fileURLs = try FileManager.default.contentsOfDirectory( - at: NSFileProviderManager.default.documentStorageURL, - includingPropertiesForKeys: nil - ) - for url in fileURLs { - try FileManager.default.removeItem(at: url) - } - UserDefaults.shared.fpStorageVersion = currentFpStorageVersion - } catch { - // Silently handle error - } - } - - let updatedDomains = drives.map { - NSFileProviderDomain( - identifier: NSFileProviderDomainIdentifier($0.objectId), - displayName: "\($0.name) (\(user.email))", - pathRelativeToDocumentStorage: "\($0.objectId)" - ) - } - Task { - do { - let allDomains = try await NSFileProviderManager.domains() - var domainsForCurrentUser = allDomains.filter { $0.identifier.rawValue.hasSuffix("_\(user.id)") } - await withThrowingTaskGroup(of: Void.self) { group in - for newDomain in updatedDomains { - // Check if domain already added - if let existingDomainIndex = domainsForCurrentUser - .firstIndex(where: { $0.identifier == newDomain.identifier }) { - let existingDomain = domainsForCurrentUser.remove(at: existingDomainIndex) - // Domain exists but its name could have changed - if existingDomain.displayName != newDomain.displayName { - group.addTask { - try await NSFileProviderManager.remove(existingDomain) - try await NSFileProviderManager.add(newDomain) - } - } - } else { - // Domain didn't exist we have to add it - group.addTask { - try await NSFileProviderManager.add(newDomain) - } - } - } - } - - // Remove left domains - await withThrowingTaskGroup(of: Void.self) { group in - for domain in domainsForCurrentUser { - group.addTask { - try await NSFileProviderManager.remove(domain) - } - } - } - } catch { - DDLogError("Error while updating file provider domains: \(error)") - } - } - } - - func deleteFileProviderDomains(for userId: Int) { - NSFileProviderManager.getDomainsWithCompletionHandler { allDomains, error in - if let error { - DDLogError("Error while getting domains: \(error)") - } - - let domainsForCurrentUser = allDomains.filter { $0.identifier.rawValue.hasSuffix("_\(userId)") } - for domain in domainsForCurrentUser { - NSFileProviderManager.remove(domain) { error in - if let error { - DDLogError("Error while removing domain \(domain.displayName): \(error)") - } - } - } - } - } - - public func deleteAllFileProviderDomains() { - NSFileProviderManager.removeAllDomains { error in - if let error { - DDLogError("Error while removing domains: \(error)") - } - } - } - - func getFileProviderDomain(for driveId: String, completion: @escaping (NSFileProviderDomain?) -> Void) { - NSFileProviderManager.getDomainsWithCompletionHandler { domains, error in - if let error { - DDLogError("Error while getting domains: \(error)") - completion(nil) - } else { - completion(domains.first { $0.identifier.rawValue == driveId }) - } - } - } - - public func getFileProviderManager(for drive: Drive, completion: @escaping (NSFileProviderManager) -> Void) { - getFileProviderManager(for: drive.objectId, completion: completion) - } - - public func getFileProviderManager(driveId: Int, userId: Int, completion: @escaping (NSFileProviderManager) -> Void) { - let objectId = DriveInfosManager.getObjectId(driveId: driveId, userId: userId) - getFileProviderManager(for: objectId, completion: completion) - } - - public func getFileProviderManager(for driveId: String, completion: @escaping (NSFileProviderManager) -> Void) { - getFileProviderDomain(for: driveId) { domain in - if let domain { - completion(NSFileProviderManager(for: domain) ?? .default) - } else { - completion(.default) - } - } - } - @discardableResult func storeDriveResponse(user: InfomaniakCore.UserProfile, driveResponse: DriveResponse) -> [Drive] { var driveList = [Drive]() diff --git a/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Notifications.swift b/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Notifications.swift index 51451cbed..3ccc113c7 100644 --- a/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Notifications.swift +++ b/kDriveCore/Data/UploadQueue/Queue/UploadQueue+Notifications.swift @@ -42,6 +42,11 @@ extension UploadQueue: UploadNotifiable { public func sendPausedNotificationIfNeeded() { Log.uploadQueue("sendPausedNotificationIfNeeded") + guard appContextService.context != .fileProviderExtension else { + Log.uploadQueue("\(#function) disabled in FileProviderExtension", level: .error) + return + } + serialQueue.async { [weak self] in guard let self else { return } if !pausedNotificationSent { diff --git a/kDriveCore/Utils/AbstractLog+Category.swift b/kDriveCore/Utils/AbstractLog+Category.swift index 7cafd1bed..8b1b4323b 100644 --- a/kDriveCore/Utils/AbstractLog+Category.swift +++ b/kDriveCore/Utils/AbstractLog+Category.swift @@ -175,6 +175,39 @@ public enum Log { tag: tag) } + /// Shorthand for ABLog, with "DriveInfosManager" category. + /// + /// Sentry tracking enabled when level == .error + public static func driveInfosManager(_ message: @autoclosure () -> Any, + level: AbstractLogLevel = .debug, + context: Int = 0, + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line, + tag: Any? = nil) { + let category = "DriveInfosManager" + let messageAny = message() + guard let messageString = messageAny as? String else { + assertionFailure("This should always cast to a String") + return + } + + // All errors are tracked on Sentry + if level == .error { + SentryDebug.addBreadcrumb(message: messageString, category: .DriveInfosManager, level: .error) + SentryDebug.capture(message: messageString, level: .error, extras: ["function": "\(function)", "line": "\(line)"]) + } + + ABLog(messageAny, + category: category, + level: level, + context: context, + file: file, + function: function, + line: line, + tag: tag) + } + private static func defaultLogHandler(_ message: @autoclosure () -> Any, category: String, level: AbstractLogLevel, diff --git a/kDriveCore/Utils/FileProvider/FileProviderDomain+Identifier.swift b/kDriveCore/Utils/FileProvider/FileProviderDomain+Identifier.swift new file mode 100644 index 000000000..0c76b70f5 --- /dev/null +++ b/kDriveCore/Utils/FileProvider/FileProviderDomain+Identifier.swift @@ -0,0 +1,45 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import FileProvider +import Foundation + +public extension NSFileProviderDomain { + private typealias DriveIdAndUserId = (driveId: Int, userId: Int) + + private var userAndDrive: DriveIdAndUserId? { + let identifiers = identifier.rawValue.components(separatedBy: "_") + + guard let driveIdString = identifiers[safe: 0], + let usedIdString = identifiers[safe: 1], + let driveId = Int(driveIdString), + let usedId = Int(usedIdString) else { + return nil + } + + return (driveId, usedId) + } + + var userId: Int? { + userAndDrive?.userId + } + + var driveId: Int? { + userAndDrive?.driveId + } +} diff --git a/kDriveCore/Utils/Sentry/SentryDebug.swift b/kDriveCore/Utils/Sentry/SentryDebug.swift index 20b4bc3e4..2d1decb03 100644 --- a/kDriveCore/Utils/Sentry/SentryDebug.swift +++ b/kDriveCore/Utils/Sentry/SentryDebug.swift @@ -38,6 +38,8 @@ public enum SentryDebug { case realmMigration = "RealmMigration" /// Photo library assets case PHAsset + /// DriveInfosManager, and communication with FileProvider APIs + case DriveInfosManager } public enum EventNames { @@ -136,6 +138,7 @@ public enum SentryDebug { message: String, context: [String: Any]? = nil, contextKey: String? = nil, + level: SentryLevel? = nil, extras: [String: Any]? = nil ) { Task { @@ -144,6 +147,10 @@ public enum SentryDebug { scope.setContext(value: context, key: contextKey) } + if let level { + scope.setLevel(level) + } + if let extras { scope.setExtras(extras) } diff --git a/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift b/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift index f9cbab131..460c8bd21 100644 --- a/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift +++ b/kDriveTests/kDrive/Launch/ITAppLaunchTest.swift @@ -43,6 +43,10 @@ final class ITAppLaunchTest: XCTestCase { SimpleResolver.register(FactoryService.debugServices) let services = [ + Factory(type: AppContextServiceable.self) { _, _ in + // We fake the main app context + return AppContextService(context: .app) + }, Factory(type: InfomaniakNetworkLogin.self) { _, _ in return InfomaniakNetworkLogin(config: self.loginConfig) }, diff --git a/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift b/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift index e915f0080..f66a4f11c 100644 --- a/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift +++ b/kDriveTests/kDrive/Launch/UTRootViewControllerState.swift @@ -42,6 +42,10 @@ final class UTRootViewControllerState: XCTestCase { SimpleResolver.sharedResolver.removeAll() let services = [ + Factory(type: AppContextServiceable.self) { _, _ in + // We fake the main app context + return AppContextService(context: .app) + }, Factory(type: InfomaniakNetworkLogin.self) { _, _ in InfomaniakNetworkLogin(config: self.loginConfig) },