From 2bb4c4d2787bf9aa3db12446b54601a2d59dd6d8 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Sat, 14 Jan 2017 11:27:18 -0800 Subject: [PATCH] Implements error handling when importing games + controller skins --- Delta.xcodeproj/project.pbxproj | 4 + Delta/AppDelegate.swift | 30 ++++- .../Importing/ImportController.swift | 40 ++---- Delta/Database/DatabaseManager.swift | 119 +++++++++++++++--- .../UIAlertController+Importing.swift | 80 ++++++++++++ .../Game Selection/GamesViewController.swift | 26 +++- External/Roxas | 2 +- 7 files changed, 248 insertions(+), 53 deletions(-) create mode 100644 Delta/Extensions/UIAlertController+Importing.swift diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 738ebe4..1a26bf4 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ BF1173501DA32CF600047DF8 /* ControllersSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF11734F1DA32CF600047DF8 /* ControllersSettingsViewController.swift */; }; BF13A7561D5D29B0000BB055 /* PreviewGameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF13A7551D5D29B0000BB055 /* PreviewGameViewController.swift */; }; BF13A7581D5D2FD9000BB055 /* EmulatorCore+Cheats.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF13A7571D5D2FD9000BB055 /* EmulatorCore+Cheats.swift */; }; + BF18B61F1E2985F900F70067 /* UIAlertController+Importing.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF18B61E1E2985F900F70067 /* UIAlertController+Importing.swift */; }; BF1DAD5D1D9F576000E752A7 /* GameTypeControllerSkinsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1DAD5C1D9F576000E752A7 /* GameTypeControllerSkinsViewController.swift */; }; BF27CC8E1BC9FEA200A20D89 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BF6BB2451BB73FE800CCF94A /* Assets.xcassets */; }; BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF2B98E51C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift */; }; @@ -133,6 +134,7 @@ BF11734F1DA32CF600047DF8 /* ControllersSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ControllersSettingsViewController.swift; path = Controllers/ControllersSettingsViewController.swift; sourceTree = ""; }; BF13A7551D5D29B0000BB055 /* PreviewGameViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewGameViewController.swift; sourceTree = ""; }; BF13A7571D5D2FD9000BB055 /* EmulatorCore+Cheats.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EmulatorCore+Cheats.swift"; sourceTree = ""; }; + BF18B61E1E2985F900F70067 /* UIAlertController+Importing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Importing.swift"; sourceTree = ""; }; BF1DAD5C1D9F576000E752A7 /* GameTypeControllerSkinsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GameTypeControllerSkinsViewController.swift; path = "Controller Skins/GameTypeControllerSkinsViewController.swift"; sourceTree = ""; }; BF27CC861BC9E3C600A20D89 /* Delta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Delta.entitlements; sourceTree = ""; }; BF27CC8A1BC9FE4D00A20D89 /* Pods.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Pods.framework; path = "Pods/../build/Debug-appletvos/Pods.framework"; sourceTree = ""; }; @@ -239,6 +241,7 @@ BF5942911E09BD1A0051894B /* NSManagedObject+Conveniences.swift */, BF5942921E09BD1A0051894B /* NSManagedObjectContext+Conveniences.swift */, BFC314761E0C8CFC0056E3A8 /* GameType+Delta.swift */, + BF18B61E1E2985F900F70067 /* UIAlertController+Importing.swift */, ); path = Extensions; sourceTree = ""; @@ -717,6 +720,7 @@ BF5942731E09BC700051894B /* Model.xcdatamodel in Sources */, BF5942951E09BD1A0051894B /* NSManagedObjectContext+Conveniences.swift in Sources */, BF353FF91C5D870B00C1184C /* PauseItem.swift in Sources */, + BF18B61F1E2985F900F70067 /* UIAlertController+Importing.swift in Sources */, BFDD04F11D5E2C27002D450E /* GameCollectionViewController.swift in Sources */, BFE4269E1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift in Sources */, BFFA71DD1AAC406100EE9DD1 /* AppDelegate.swift in Sources */, diff --git a/Delta/AppDelegate.swift b/Delta/AppDelegate.swift index 7234277..8045b36 100644 --- a/Delta/AppDelegate.swift +++ b/Delta/AppDelegate.swift @@ -97,14 +97,40 @@ extension AppDelegate private func importGame(at url: URL) -> Bool { - DatabaseManager.shared.importGames(at: [url], completion: nil) + DatabaseManager.shared.importGames(at: [url]) { (games, errors) in + if errors.count > 0 + { + let alertController = UIAlertController.alertController(for: .games, with: errors) + self.present(alertController) + } + } + return true } private func importControllerSkin(at url: URL) -> Bool { - DatabaseManager.shared.importControllerSkins(at: [url], completion: nil) + DatabaseManager.shared.importControllerSkins(at: [url]) { (games, errors) in + if errors.count > 0 + { + let alertController = UIAlertController.alertController(for: .controllerSkins, with: errors) + self.present(alertController) + } + } + return true } + + private func present(_ alertController: UIAlertController) + { + var rootViewController = self.window?.rootViewController + + while rootViewController?.presentedViewController != nil + { + rootViewController = rootViewController?.presentedViewController + } + + rootViewController?.present(alertController, animated: true, completion: nil) + } } diff --git a/Delta/Components/Importing/ImportController.swift b/Delta/Components/Importing/ImportController.swift index c6d0e27..ae3864d 100644 --- a/Delta/Components/Importing/ImportController.swift +++ b/Delta/Components/Importing/ImportController.swift @@ -15,8 +15,8 @@ import MobileCoreServices protocol ImportControllerDelegate { - func importController(_ importController: ImportController, didImport games: Set) - func importController(_ importController: ImportController, didImport controllerSkins: Set) + func importController(_ importController: ImportController, didImport games: Set, with errors: Set) + func importController(_ importController: ImportController, didImport controllerSkins: Set, with errors: Set) /** Optional **/ func importControllerDidCancel(_ importController: ImportController) @@ -73,10 +73,10 @@ class ImportController: NSObject DatabaseManager.shared.performBackgroundTask { (context) in let controllerSkinURLs = contents.filter { $0.pathExtension.lowercased() == "deltaskin" } - self.importControllerSkins(at: controllerSkinURLs) + self.importControllerSkins(at: Set(controllerSkinURLs)) let gameURLs = contents.filter { GameType.gameType(forFileExtension: $0.pathExtension) != .unknown || $0.pathExtension.lowercased() == "zip" } - self.importGames(at: gameURLs) + self.importGames(at: Set(gameURLs)) } } @@ -99,37 +99,17 @@ class ImportController: NSObject self.presentingViewController?.present(alertController, animated: true, completion: completion) } - fileprivate func importGames(at urls: [URL]) + fileprivate func importGames(at urls: Set) { - DatabaseManager.shared.importGames(at: urls) { identifiers in - - DatabaseManager.shared.viewContext.perform() { - - let predicate = NSPredicate(format: "%K IN (%@)", #keyPath(Game.identifier), identifiers) - let games = Game.instancesWithPredicate(predicate, inManagedObjectContext: DatabaseManager.shared.viewContext, type: Game.self) - - self.delegate?.importController(self, didImport: Set(games)) - - self.presentingViewController?.importController = nil - - } + DatabaseManager.shared.importGames(at: urls) { (games, errors) in + self.delegate?.importController(self, didImport: games, with: errors) } } - fileprivate func importControllerSkins(at urls: [URL]) + fileprivate func importControllerSkins(at urls: Set) { - DatabaseManager.shared.importControllerSkins(at: urls) { identifiers in - - DatabaseManager.shared.viewContext.perform() { - - let predicate = NSPredicate(format: "%K IN (%@)", #keyPath(ControllerSkin.identifier), identifiers) - let controllerSkins = ControllerSkin.instancesWithPredicate(predicate, inManagedObjectContext: DatabaseManager.shared.viewContext, type: ControllerSkin.self) - - self.delegate?.importController(self, didImport: Set(controllerSkins)) - - self.presentingViewController?.importController = nil - - } + DatabaseManager.shared.importControllerSkins(at: urls) { (controllerSkins, errors) in + self.delegate?.importController(self, didImport: controllerSkins, with: errors) } } } diff --git a/Delta/Database/DatabaseManager.swift b/Delta/Database/DatabaseManager.swift index 0239fc6..ccb77d2 100644 --- a/Delta/Database/DatabaseManager.swift +++ b/Delta/Database/DatabaseManager.swift @@ -16,6 +16,43 @@ import ZipZap // Pods import FileMD5Hash +extension DatabaseManager +{ + enum ImportError: Error, Hashable + { + case doesNotExist(URL) + case invalid(URL) + case unknown(URL, NSError) + case saveFailed(Set, NSError) + + var hashValue: Int { + switch self + { + case .doesNotExist: return 0 + case .invalid: return 1 + case .unknown: return 2 + case .saveFailed: return 3 + } + } + + static func ==(lhs: ImportError, rhs: ImportError) -> Bool + { + switch (lhs, rhs) + { + case (let .doesNotExist(url1), let .doesNotExist(url2)) where url1 == url2: return true + case (let .invalid(url1), let .invalid(url2)) where url1 == url2: return true + case (let .unknown(url1, error1), let .unknown(url2, error2)) where url1 == url2 && error1 == error2: return true + case (let .saveFailed(urls1, error1), let .saveFailed(urls2, error2)) where urls1 == urls2 && error1 == error2: return true + case (.doesNotExist, _): return false + case (.invalid, _): return false + case (.unknown, _): return false + case (.saveFailed, _): return false + } + } + + } +} + final class DatabaseManager: NSPersistentContainer { static let shared = DatabaseManager() @@ -108,14 +145,19 @@ private extension DatabaseManager /// Importing extension DatabaseManager { - func importGames(at urls: [URL], completion: ((Set) -> Void)?) + func importGames(at urls: Set, completion: ((Set, Set) -> Void)?) { + var errors = Set() + let zipFileURLs = urls.filter { $0.pathExtension.lowercased() == "zip" } if zipFileURLs.count > 0 { - self.extractCompressedGames(at: zipFileURLs) { (extractedURLs) in + self.extractCompressedGames(at: Set(zipFileURLs)) { (extractedURLs, extractErrors) in let gameURLs = urls.filter { $0.pathExtension.lowercased() != "zip" } + extractedURLs - self.importGames(at: gameURLs, completion: completion) + self.importGames(at: Set(gameURLs)) { (importedGames, importErrors) in + let allErrors = importErrors.union(extractErrors) + completion?(importedGames, allErrors) + } } return @@ -127,7 +169,10 @@ extension DatabaseManager for url in urls { - guard FileManager.default.fileExists(atPath: url.path) else { continue } + guard FileManager.default.fileExists(atPath: url.path) else { + errors.insert(.doesNotExist(url)) + continue + } let identifier = FileHash.sha1HashOfFile(atPath: url.path) as String @@ -159,38 +204,56 @@ extension DatabaseManager identifiers.insert(game.identifier) } - catch + catch let error as NSError { print("Import Games error:", error) game.managedObjectContext?.delete(game) + + errors.insert(.unknown(url, error)) } } - + do { try context.save() } - catch + catch let error as NSError { print("Failed to save import context:", error) identifiers.removeAll() + + errors.insert(.saveFailed(urls, error)) } - completion?(identifiers) + DatabaseManager.shared.viewContext.perform { + let predicate = NSPredicate(format: "%K IN (%@)", #keyPath(Game.identifier), identifiers) + let games = Game.instancesWithPredicate(predicate, inManagedObjectContext: DatabaseManager.shared.viewContext, type: Game.self) + completion?(Set(games), errors) + } } } - func importControllerSkins(at urls: [URL], completion: ((Set) -> Void)?) + func importControllerSkins(at urls: Set, completion: ((Set, Set) -> Void)?) { + var errors = Set() + self.performBackgroundTask { (context) in var identifiers = Set() for url in urls { - guard let deltaControllerSkin = DeltaCore.ControllerSkin(fileURL: url) else { continue } + guard FileManager.default.fileExists(atPath: url.path) else { + errors.insert(.doesNotExist(url)) + continue + } + + guard let deltaControllerSkin = DeltaCore.ControllerSkin(fileURL: url) else { + errors.insert(.invalid(url)) + continue + } let controllerSkin = ControllerSkin(context: context) controllerSkin.filename = deltaControllerSkin.identifier + ".deltaskin" @@ -212,10 +275,12 @@ extension DatabaseManager identifiers.insert(controllerSkin.identifier) } - catch + catch let error as NSError { print("Import Controller Skins error:", error) controllerSkin.managedObjectContext?.delete(controllerSkin) + + errors.insert(.unknown(url, error)) } } @@ -223,26 +288,35 @@ extension DatabaseManager { try context.save() } - catch + catch let error as NSError { print("Failed to save controller skin import context:", error) identifiers.removeAll() + + errors.insert(.saveFailed(urls, error)) } - completion?(identifiers) + DatabaseManager.shared.viewContext.perform { + let predicate = NSPredicate(format: "%K IN (%@)", #keyPath(Game.identifier), identifiers) + let controllerSkins = ControllerSkin.instancesWithPredicate(predicate, inManagedObjectContext: DatabaseManager.shared.viewContext, type: ControllerSkin.self) + completion?(Set(controllerSkins), errors) + } } } - private func extractCompressedGames(at urls: [URL], completion: @escaping ((Set) -> Void)) + private func extractCompressedGames(at urls: Set, completion: @escaping ((Set, Set) -> Void)) { DispatchQueue.global().async { var semaphores = Set() var outputURLs = Set() + var errors = Set() for url in urls { + var archiveContainsValidGameFile = false + do { let archive = try ZZArchive(url: url) @@ -257,6 +331,11 @@ extension DatabaseManager guard gameType != .unknown else { continue } + // At least one entry is a valid game file, so we set archiveContainsValidGameFile to true + // This will result in this archive being considered valid, and thus we will not return an ImportError.invalid error for the archive + // However, if this game file does turn out to be invalid when extracting, we'll return an ImportError.invalid error specific to this game file + archiveContainsValidGameFile = true + // ROMs may potentially be very large, so we extract using file streams and not raw Data let inputStream = try entry.newStream() @@ -290,12 +369,15 @@ extension DatabaseManager } print(error) + + errors.insert(.invalid(outputURL)) } else { outputURLs.insert(outputURL) - semaphore.signal() } + + semaphore.signal() } } } @@ -303,6 +385,11 @@ extension DatabaseManager { print(error) } + + if !archiveContainsValidGameFile + { + errors.insert(.invalid(url)) + } } for semaphore in semaphores @@ -325,7 +412,7 @@ extension DatabaseManager } } - completion(outputURLs) + completion(outputURLs, errors) } } } diff --git a/Delta/Extensions/UIAlertController+Importing.swift b/Delta/Extensions/UIAlertController+Importing.swift new file mode 100644 index 0000000..adac8c1 --- /dev/null +++ b/Delta/Extensions/UIAlertController+Importing.swift @@ -0,0 +1,80 @@ +// +// UIAlertController+Importing.swift +// Delta +// +// Created by Riley Testut on 1/13/17. +// Copyright © 2017 Riley Testut. All rights reserved. +// + +import UIKit + +import Roxas + +extension UIAlertController +{ + enum ImportType + { + case games + case controllerSkins + } + + 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 + { + switch error + { + case .doesNotExist(let url): urls.insert(url) + case .invalid(let url): urls.insert(url) + case .unknown(let url, _): urls.insert(url) + case .saveFailed(let errorURLs, _): urls.formUnion(errorURLs) + } + } + + let filenames = urls.map{ $0.lastPathComponent }.sorted() + + if filenames.count > 0 + { + 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 + } + 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: "") + } + } + + alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("OK"), style: .cancel, handler: nil)) + + return alertController + } +} diff --git a/Delta/Game Selection/GamesViewController.swift b/Delta/Game Selection/GamesViewController.swift index 01f76ad..125b505 100644 --- a/Delta/Game Selection/GamesViewController.swift +++ b/Delta/Game Selection/GamesViewController.swift @@ -276,14 +276,32 @@ extension GamesViewController: ImportControllerDelegate } //MARK: - ImportControllerDelegate - @nonobjc func importController(_ importController: ImportController, didImport games: Set) + @nonobjc func importController(_ importController: ImportController, didImport games: Set, with errors: Set) { - print(games) + if errors.count > 0 + { + let alertController = UIAlertController.alertController(for: .games, with: errors) + self.present(alertController, animated: true, completion: nil) + } + + if games.count > 0 + { + print("Imported Games:", games.map { $0.name }) + } } - @nonobjc func importController(_ importController: ImportController, didImport controllerSkins: Set) + @nonobjc func importController(_ importController: ImportController, didImport controllerSkins: Set, with errors: Set) { - print(controllerSkins) + if errors.count > 0 + { + let alertController = UIAlertController.alertController(for: .controllerSkins, with: errors) + self.present(alertController, animated: true, completion: nil) + } + + if controllerSkins.count > 0 + { + print("Imported Controller Skins:", controllerSkins.map { $0.name }) + } } } diff --git a/External/Roxas b/External/Roxas index 4ceaf19..df42ea9 160000 --- a/External/Roxas +++ b/External/Roxas @@ -1 +1 @@ -Subproject commit 4ceaf192ac161e04adf4a1b6ab964ddf689367b4 +Subproject commit df42ea9b1f360756387995917541e5fe7a6178e4