Implements error handling when importing games + controller skins
This commit is contained in:
parent
4d5dee6cea
commit
2bb4c4d278
@ -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 = "<group>"; };
|
||||
BF13A7551D5D29B0000BB055 /* PreviewGameViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewGameViewController.swift; sourceTree = "<group>"; };
|
||||
BF13A7571D5D2FD9000BB055 /* EmulatorCore+Cheats.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EmulatorCore+Cheats.swift"; sourceTree = "<group>"; };
|
||||
BF18B61E1E2985F900F70067 /* UIAlertController+Importing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Importing.swift"; sourceTree = "<group>"; };
|
||||
BF1DAD5C1D9F576000E752A7 /* GameTypeControllerSkinsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GameTypeControllerSkinsViewController.swift; path = "Controller Skins/GameTypeControllerSkinsViewController.swift"; sourceTree = "<group>"; };
|
||||
BF27CC861BC9E3C600A20D89 /* Delta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Delta.entitlements; sourceTree = "<group>"; };
|
||||
BF27CC8A1BC9FE4D00A20D89 /* Pods.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Pods.framework; path = "Pods/../build/Debug-appletvos/Pods.framework"; sourceTree = "<group>"; };
|
||||
@ -239,6 +241,7 @@
|
||||
BF5942911E09BD1A0051894B /* NSManagedObject+Conveniences.swift */,
|
||||
BF5942921E09BD1A0051894B /* NSManagedObjectContext+Conveniences.swift */,
|
||||
BFC314761E0C8CFC0056E3A8 /* GameType+Delta.swift */,
|
||||
BF18B61E1E2985F900F70067 /* UIAlertController+Importing.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@ -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 */,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,8 +15,8 @@ import MobileCoreServices
|
||||
|
||||
protocol ImportControllerDelegate
|
||||
{
|
||||
func importController(_ importController: ImportController, didImport games: Set<Game>)
|
||||
func importController(_ importController: ImportController, didImport controllerSkins: Set<ControllerSkin>)
|
||||
func importController(_ importController: ImportController, didImport games: Set<Game>, with errors: Set<DatabaseManager.ImportError>)
|
||||
func importController(_ importController: ImportController, didImport controllerSkins: Set<ControllerSkin>, with errors: Set<DatabaseManager.ImportError>)
|
||||
|
||||
/** 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<URL>)
|
||||
{
|
||||
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<URL>)
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<URL>, 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<String>) -> Void)?)
|
||||
func importGames(at urls: Set<URL>, completion: ((Set<Game>, Set<ImportError>) -> Void)?)
|
||||
{
|
||||
var errors = Set<ImportError>()
|
||||
|
||||
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<String>) -> Void)?)
|
||||
func importControllerSkins(at urls: Set<URL>, completion: ((Set<ControllerSkin>, Set<ImportError>) -> Void)?)
|
||||
{
|
||||
var errors = Set<ImportError>()
|
||||
|
||||
self.performBackgroundTask { (context) in
|
||||
|
||||
var identifiers = Set<String>()
|
||||
|
||||
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<URL>) -> Void))
|
||||
private func extractCompressedGames(at urls: Set<URL>, completion: @escaping ((Set<URL>, Set<ImportError>) -> Void))
|
||||
{
|
||||
DispatchQueue.global().async {
|
||||
|
||||
var semaphores = Set<DispatchSemaphore>()
|
||||
var outputURLs = Set<URL>()
|
||||
var errors = Set<ImportError>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
Delta/Extensions/UIAlertController+Importing.swift
Normal file
80
Delta/Extensions/UIAlertController+Importing.swift
Normal file
@ -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<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
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -276,14 +276,32 @@ extension GamesViewController: ImportControllerDelegate
|
||||
}
|
||||
|
||||
//MARK: - ImportControllerDelegate
|
||||
@nonobjc func importController(_ importController: ImportController, didImport games: Set<Game>)
|
||||
@nonobjc func importController(_ importController: ImportController, didImport games: Set<Game>, with errors: Set<DatabaseManager.ImportError>)
|
||||
{
|
||||
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<ControllerSkin>)
|
||||
@nonobjc func importController(_ importController: ImportController, didImport controllerSkins: Set<ControllerSkin>, with errors: Set<DatabaseManager.ImportError>)
|
||||
{
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
External/Roxas
vendored
2
External/Roxas
vendored
@ -1 +1 @@
|
||||
Subproject commit 4ceaf192ac161e04adf4a1b6ab964ddf689367b4
|
||||
Subproject commit df42ea9b1f360756387995917541e5fe7a6178e4
|
||||
Loading…
Reference in New Issue
Block a user