diff --git a/Delta/Database/DatabaseManager.swift b/Delta/Database/DatabaseManager.swift index 1f689fd..3485d83 100644 --- a/Delta/Database/DatabaseManager.swift +++ b/Delta/Database/DatabaseManager.swift @@ -23,13 +23,24 @@ extension DatabaseManager extension DatabaseManager { - enum ImportError: Error, Hashable, Equatable + enum ImportError: LocalizedError, Hashable, Equatable { case doesNotExist(URL) case invalid(URL) case unsupported(URL) case unknown(URL, NSError) case saveFailed(Set, NSError) + + var errorDescription: String? { + switch self + { + case .doesNotExist: return NSLocalizedString("The file does not exist.", comment: "") + case .invalid: return NSLocalizedString("The file is invalid.", comment: "") + case .unsupported: return NSLocalizedString("This file is not supported.", comment: "") + case .unknown(_, let error): return error.localizedDescription + case .saveFailed(_, let error): return error.localizedDescription + } + } } } @@ -43,6 +54,8 @@ final class DatabaseManager: RSTPersistentContainer private var validationManagedObjectContext: NSManagedObjectContext? + private let importController = ImportController(documentTypes: []) + private init() { guard @@ -218,7 +231,20 @@ extension DatabaseManager { func importGames(at urls: Set, completion: ((Set, Set) -> Void)?) { - var errors = Set() + let externalFileURLs = urls.filter { !FileManager.default.isReadableFile(atPath: $0.path) } + guard externalFileURLs.isEmpty else { + self.importExternalFiles(at: externalFileURLs) { (importedURLs, externalImportErrors) in + var availableFileURLs = urls.filter { !externalFileURLs.contains($0) } + availableFileURLs.formUnion(importedURLs) + + self.importGames(at: Set(availableFileURLs)) { (importedGames, importErrors) in + let allErrors = importErrors.union(externalImportErrors) + completion?(importedGames, allErrors) + } + } + + return + } let zipFileURLs = urls.filter { $0.pathExtension.lowercased() == "zip" } if zipFileURLs.count > 0 @@ -236,6 +262,7 @@ extension DatabaseManager self.performBackgroundTask { (context) in + var errors = Set() var identifiers = Set() for url in urls @@ -331,10 +358,24 @@ extension DatabaseManager func importControllerSkins(at urls: Set, completion: ((Set, Set) -> Void)?) { - var errors = Set() + let externalFileURLs = urls.filter { !FileManager.default.isReadableFile(atPath: $0.path) } + guard externalFileURLs.isEmpty else { + self.importExternalFiles(at: externalFileURLs) { (importedURLs, externalImportErrors) in + var availableFileURLs = urls.filter { !externalFileURLs.contains($0) } + availableFileURLs.formUnion(importedURLs) + + self.importControllerSkins(at: Set(availableFileURLs)) { (importedSkins, importErrors) in + let allErrors = importErrors.union(externalImportErrors) + completion?(importedSkins, allErrors) + } + } + + return + } self.performBackgroundTask { (context) in + var errors = Set() var identifiers = Set() for url in urls @@ -473,6 +514,32 @@ extension DatabaseManager completion(outputURLs, errors) } } + + private func importExternalFiles(at urls: Set, completion: @escaping ((Set, Set) -> Void)) + { + var outputURLs = Set() + var errors = Set() + + let dispatchGroup = DispatchGroup() + for url in urls + { + dispatchGroup.enter() + + self.importController.importExternalFile(at: url) { (result) in + switch result + { + case .failure(let error): errors.insert(.unknown(url, error as NSError)) + case .success(let fileURL): outputURLs.insert(fileURL) + } + + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .global()) { + completion(outputURLs, errors) + } + } } //MARK: - File URLs - diff --git a/Delta/Extensions/UIAlertController+Importing.swift b/Delta/Extensions/UIAlertController+Importing.swift index f326d36..90282d8 100644 --- a/Delta/Extensions/UIAlertController+Importing.swift +++ b/Delta/Extensions/UIAlertController+Importing.swift @@ -20,16 +20,6 @@ extension UIAlertController class func alertController(for importType: ImportType, with errors: Set) -> UIAlertController { - let title: String - - switch importType - { - case .games: title = NSLocalizedString("Error Importing Games", comment: "") - case .controllerSkins: title = NSLocalizedString("Error Importing Controller Skins", comment: "") - } - - let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert) - var urls = Set() for error in errors @@ -44,38 +34,54 @@ extension UIAlertController } } - let filenames = urls.map{ $0.lastPathComponent }.sorted() + let title: String + let message: String - if filenames.count > 0 + if let fileURL = urls.first, let error = errors.first, errors.count == 1 { - var message: String - - switch importType - { - case .games: message = NSLocalizedString("The following game files could not be imported:", comment: "") + "\n" - case .controllerSkins: message = NSLocalizedString("The following controller skin files could not be imported:", comment: "") + "\n" - } - - for filename in filenames - { - message += "\n" + filename - } - - alertController.message = message + title = String(format: NSLocalizedString("Could not import “%@”.", comment: ""), fileURL.lastPathComponent) + message = error.localizedDescription } else { - // This branch can be executed when there are no input URLs when importing, but there is an error saving the database anyway. - switch importType { - case .games: alertController.message = NSLocalizedString("Delta was unable to import games. Please try again later.", comment: "") - case .controllerSkins: alertController.message = NSLocalizedString("Delta was unable to import controller skins. Please try again later.", comment: "") + case .games: title = NSLocalizedString("Error Importing Games", comment: "") + case .controllerSkins: title = NSLocalizedString("Error Importing Controller Skins", comment: "") + } + + if urls.count > 0 + { + var tempMessage: String + + switch importType + { + case .games: tempMessage = NSLocalizedString("The following game files could not be imported:", comment: "") + "\n" + case .controllerSkins: tempMessage = NSLocalizedString("The following controller skin files could not be imported:", comment: "") + "\n" + } + + let filenames = urls.map { $0.lastPathComponent }.sorted() + for filename in filenames + { + tempMessage += "\n" + filename + } + + message = tempMessage + } + else + { + // This branch can be executed when there are no input URLs when importing, but there is an error saving the database anyway. + + switch importType + { + case .games: message = NSLocalizedString("Delta was unable to import games. Please try again later.", comment: "") + case .controllerSkins: message = NSLocalizedString("Delta was unable to import controller skins. Please try again later.", comment: "") + } } } + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("OK"), style: .cancel, handler: nil)) - return alertController } } diff --git a/Delta/Importing/ImportController.swift b/Delta/Importing/ImportController.swift index 0c7d878..e1cc5a9 100644 --- a/Delta/Importing/ImportController.swift +++ b/Delta/Importing/ImportController.swift @@ -131,6 +131,39 @@ class ImportController: NSObject } } +extension ImportController +{ + func importExternalFile(at fileURL: URL, completionHandler: @escaping (Result) -> Void) + { + let intent = NSFileAccessIntent.readingIntent(with: fileURL) + self.fileCoordinator.coordinate(with: [intent], queue: self.importQueue) { (error) in + do + { + if let error = error + { + throw error + } + else + { + // User intent.url, not url, as the system may have updated it when requesting access. + guard intent.url.startAccessingSecurityScopedResource() else { throw CocoaError.error(.fileReadNoPermission) } + defer { intent.url.stopAccessingSecurityScopedResource() } + + // Use url, not intent.url, to ensure the file name matches what was in the document browser. + let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent) + try FileManager.default.copyItem(at: intent.url, to: temporaryURL, shouldReplace: true) + + completionHandler(.success(temporaryURL)) + } + } + catch + { + completionHandler(.failure(error)) + } + } + } +} + extension ImportController: UIDocumentBrowserViewControllerDelegate { func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentURLs documentURLs: [URL]) @@ -144,31 +177,11 @@ extension ImportController: UIDocumentBrowserViewControllerDelegate { dispatchGroup.enter() - let intent = NSFileAccessIntent.readingIntent(with: url) - self.fileCoordinator.coordinate(with: [intent], queue: self.importQueue) { (error) in - - do + self.importExternalFile(at: url) { (result) in + switch result { - if let error = error - { - throw error - } - else - { - // User intent.url, not url, as the system may have updated it when requesting access. - guard intent.url.startAccessingSecurityScopedResource() else { throw CocoaError.error(.fileReadNoPermission) } - defer { intent.url.stopAccessingSecurityScopedResource() } - - // Use url, not intent.url, to ensure the file name matches what was in the document browser. - let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) - try FileManager.default.copyItem(at: intent.url, to: temporaryURL, shouldReplace: true) - - coordinatedURLs.insert(temporaryURL) - } - } - catch - { - errors.append(error) + case .failure(let error): errors.append(error) + case .success(let fileURL): coordinatedURLs.insert(fileURL) } dispatchGroup.leave()