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
{
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<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 let importController = ImportController(documentTypes: [])
private init()
{
guard
@ -218,7 +231,20 @@ extension DatabaseManager
{
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" }
if zipFileURLs.count > 0
@ -236,6 +262,7 @@ extension DatabaseManager
self.performBackgroundTask { (context) in
var errors = Set<ImportError>()
var identifiers = Set<String>()
for url in urls
@ -331,10 +358,24 @@ extension DatabaseManager
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
var errors = Set<ImportError>()
var identifiers = Set<String>()
for url in urls
@ -473,6 +514,32 @@ extension DatabaseManager
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 -

View File

@ -20,16 +20,6 @@ extension 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>()
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
}
}

View File

@ -131,6 +131,39 @@ class ImportController: NSObject
}
}
extension ImportController
{
func importExternalFile(at fileURL: URL, completionHandler: @escaping (Result<URL, Error>) -> 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()