Fixes importing games + controller skins from Files

Exposing Documents directory in Files app requires us to support opening files in place (despite LSSupportsOpeningDocumentsInPlace set to NO in Info.plist), so we now coordinate access to any external file URL
This commit is contained in:
Riley Testut 2020-04-28 14:44:06 -07:00
parent a739926e17
commit 5c9332e61e
3 changed files with 144 additions and 58 deletions

View File

@ -23,13 +23,24 @@ extension DatabaseManager
extension DatabaseManager extension DatabaseManager
{ {
enum ImportError: Error, Hashable, Equatable enum ImportError: LocalizedError, Hashable, Equatable
{ {
case doesNotExist(URL) case doesNotExist(URL)
case invalid(URL) case invalid(URL)
case unsupported(URL) case unsupported(URL)
case unknown(URL, NSError) case unknown(URL, NSError)
case saveFailed(Set<URL>, NSError) case saveFailed(Set<URL>, 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 var validationManagedObjectContext: NSManagedObjectContext?
private let importController = ImportController(documentTypes: [])
private init() private init()
{ {
guard guard
@ -218,7 +231,20 @@ extension DatabaseManager
{ {
func importGames(at urls: Set<URL>, completion: ((Set<Game>, Set<ImportError>) -> Void)?) func importGames(at urls: Set<URL>, completion: ((Set<Game>, Set<ImportError>) -> Void)?)
{ {
var errors = Set<ImportError>() 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" } let zipFileURLs = urls.filter { $0.pathExtension.lowercased() == "zip" }
if zipFileURLs.count > 0 if zipFileURLs.count > 0
@ -236,6 +262,7 @@ extension DatabaseManager
self.performBackgroundTask { (context) in self.performBackgroundTask { (context) in
var errors = Set<ImportError>()
var identifiers = Set<String>() var identifiers = Set<String>()
for url in urls for url in urls
@ -331,10 +358,24 @@ extension DatabaseManager
func importControllerSkins(at urls: Set<URL>, completion: ((Set<ControllerSkin>, Set<ImportError>) -> Void)?) func importControllerSkins(at urls: Set<URL>, completion: ((Set<ControllerSkin>, Set<ImportError>) -> Void)?)
{ {
var errors = Set<ImportError>() 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 self.performBackgroundTask { (context) in
var errors = Set<ImportError>()
var identifiers = Set<String>() var identifiers = Set<String>()
for url in urls for url in urls
@ -473,6 +514,32 @@ extension DatabaseManager
completion(outputURLs, errors) completion(outputURLs, errors)
} }
} }
private func importExternalFiles(at urls: Set<URL>, completion: @escaping ((Set<URL>, Set<ImportError>) -> Void))
{
var outputURLs = Set<URL>()
var errors = Set<ImportError>()
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 - //MARK: - File URLs -

View File

@ -20,16 +20,6 @@ extension UIAlertController
class func alertController(for importType: ImportType, with errors: Set<DatabaseManager.ImportError>) -> UIAlertController class func alertController(for importType: ImportType, with errors: Set<DatabaseManager.ImportError>) -> 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<URL>() var urls = Set<URL>()
for error in errors for error in errors
@ -44,24 +34,39 @@ 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 title = String(format: NSLocalizedString("Could not import “%@”.", comment: ""), fileURL.lastPathComponent)
message = error.localizedDescription
}
else
{
switch importType
{
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 switch importType
{ {
case .games: message = NSLocalizedString("The following game files could not be imported:", comment: "") + "\n" case .games: tempMessage = 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" 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 for filename in filenames
{ {
message += "\n" + filename tempMessage += "\n" + filename
} }
alertController.message = message message = tempMessage
} }
else else
{ {
@ -69,13 +74,14 @@ extension UIAlertController
switch importType switch importType
{ {
case .games: alertController.message = NSLocalizedString("Delta was unable to import games. Please try again later.", comment: "") case .games: 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 .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)) alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("OK"), style: .cancel, handler: nil))
return alertController return alertController
} }
} }

View File

@ -131,22 +131,12 @@ class ImportController: NSObject
} }
} }
extension ImportController: UIDocumentBrowserViewControllerDelegate extension ImportController
{ {
func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentURLs documentURLs: [URL]) func importExternalFile(at fileURL: URL, completionHandler: @escaping (Result<URL, Error>) -> Void)
{ {
var coordinatedURLs = Set<URL>() let intent = NSFileAccessIntent.readingIntent(with: fileURL)
var errors = [Error]()
let dispatchGroup = DispatchGroup()
for url in documentURLs
{
dispatchGroup.enter()
let intent = NSFileAccessIntent.readingIntent(with: url)
self.fileCoordinator.coordinate(with: [intent], queue: self.importQueue) { (error) in self.fileCoordinator.coordinate(with: [intent], queue: self.importQueue) { (error) in
do do
{ {
if let error = error if let error = error
@ -160,15 +150,38 @@ extension ImportController: UIDocumentBrowserViewControllerDelegate
defer { intent.url.stopAccessingSecurityScopedResource() } defer { intent.url.stopAccessingSecurityScopedResource() }
// Use url, not intent.url, to ensure the file name matches what was in the document browser. // 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) let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent)
try FileManager.default.copyItem(at: intent.url, to: temporaryURL, shouldReplace: true) try FileManager.default.copyItem(at: intent.url, to: temporaryURL, shouldReplace: true)
coordinatedURLs.insert(temporaryURL) completionHandler(.success(temporaryURL))
} }
} }
catch catch
{ {
errors.append(error) completionHandler(.failure(error))
}
}
}
}
extension ImportController: UIDocumentBrowserViewControllerDelegate
{
func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentURLs documentURLs: [URL])
{
var coordinatedURLs = Set<URL>()
var errors = [Error]()
let dispatchGroup = DispatchGroup()
for url in documentURLs
{
dispatchGroup.enter()
self.importExternalFile(at: url) { (result) in
switch result
{
case .failure(let error): errors.append(error)
case .success(let fileURL): coordinatedURLs.insert(fileURL)
} }
dispatchGroup.leave() dispatchGroup.leave()