GBA001/Delta/Settings/Syncing/RecordVersionsViewController.swift
Riley Testut d1643dbc8f Throws SyncValidationError when downloading corrupted versions of “verified” SaveStates
After reviewing save states upon first launch, Delta will upload the verified game ID as metadata to ensure other devices don’t download remote versions with incorrect relationships.
2023-08-11 18:57:27 -05:00

428 lines
17 KiB
Swift

//
// RecordVersionsViewController.swift
// Delta
//
// Created by Riley Testut on 11/20/18.
// Copyright © 2018 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
import Harmony
extension RecordVersionsViewController
{
private enum Section: Int, CaseIterable
{
case local
case remote
}
private enum Mode
{
case restoreVersion
case resolveConflict
}
}
private class Version
{
let version: Harmony.Version
init(_ version: Harmony.Version)
{
self.version = version
}
}
class RecordVersionsViewController: UITableViewController
{
var record: Record<NSManagedObject>! {
didSet {
self.mode = self.record.isConflicted ? .resolveConflict : .restoreVersion
self.update()
}
}
private var mode = Mode.restoreVersion {
didSet {
switch self.mode
{
case .restoreVersion: self._selectedVersionIndexPath = IndexPath(item: 0, section: Section.local.rawValue)
case .resolveConflict: self._selectedVersionIndexPath = nil
}
}
}
private var versions: [Version]?
private lazy var dataSource = self.makeDataSource()
private var remoteVersionsDataSource: RSTArrayTableViewDataSource<Version> {
let compositeDataSource = self.dataSource.dataSources[1] as! RSTCompositeTableViewDataSource
let dataSource = compositeDataSource.dataSources[1] as! RSTArrayTableViewDataSource<Version>
return dataSource
}
private let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.timeStyle = .short
dateFormatter.dateStyle = .short
return dateFormatter
}()
private var isSyncingRecord = false
private var _selectedVersionIndexPath: IndexPath?
private var progressView: UIProgressView!
private var _progressObservation: NSKeyValueObservation?
@IBOutlet private var restoreButton: UIBarButtonItem!
override func viewDidLoad()
{
super.viewDidLoad()
self.progressView = UIProgressView(progressViewStyle: .bar)
self.progressView.translatesAutoresizingMaskIntoConstraints = false
self.progressView.progress = 0
if let navigationBar = self.navigationController?.navigationBar
{
navigationBar.addSubview(self.progressView)
NSLayoutConstraint.activate([self.progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor),
self.progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor),
self.progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)])
}
self.tableView.dataSource = self.dataSource
self.update()
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.fetchVersions()
}
}
private extension RecordVersionsViewController
{
func makeDataSource() -> RSTCompositeTableViewDataSource<Version>
{
func configure(_ cell: UITableViewCell, isSelected: Bool, isEnabled: Bool)
{
cell.accessoryType = isSelected ? .checkmark : .none
if isEnabled
{
cell.textLabel?.alpha = 1.0
cell.detailTextLabel?.alpha = 1.0
cell.selectionStyle = .gray
}
else
{
cell.textLabel?.alpha = 0.33
cell.detailTextLabel?.alpha = 0.33
cell.selectionStyle = .none
}
}
let localVersionsDataSource = RSTDynamicTableViewDataSource<Version>()
localVersionsDataSource.numberOfSectionsHandler = { 1 }
localVersionsDataSource.numberOfItemsHandler = { [weak self] _ in self?.record.localModificationDate != nil ? 1 : 0 } // fetchVersions() assumes this logic, so update there too.
localVersionsDataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in
guard let `self` = self else { return }
let date = self.record.localModificationDate!
cell.textLabel?.text = self.dateFormatter.string(from: date)
cell.detailTextLabel?.text = nil
let isSelected = (indexPath == self._selectedVersionIndexPath)
configure(cell, isSelected: isSelected, isEnabled: !self.isSyncingRecord)
}
let remoteVersionsDataSource = RSTArrayTableViewDataSource<Version>(items: [])
remoteVersionsDataSource.cellConfigurationHandler = { [weak self] (cell, version, indexPath) in
guard let `self` = self else { return }
cell.textLabel?.text = self.dateFormatter.string(from: version.version.date)
cell.detailTextLabel?.text = (version.version.identifier == self.record.remoteVersion?.identifier) ? self.record.remoteAuthor : nil
let isSelected = (self._selectedVersionIndexPath?.section == Section.remote.rawValue && self._selectedVersionIndexPath?.row == indexPath.row)
configure(cell, isSelected: isSelected, isEnabled: !self.isSyncingRecord)
}
let loadingDataSource = RSTDynamicTableViewDataSource<Version>()
loadingDataSource.numberOfSectionsHandler = { 1 }
loadingDataSource.numberOfItemsHandler = { [weak self] _ in (self?.versions == nil) ? 1 : 0 }
loadingDataSource.cellIdentifierHandler = { _ in "LoadingCell" }
loadingDataSource.cellConfigurationHandler = { (_, _, _) in }
let remoteVersionsLoadingDataSource = RSTCompositeTableViewDataSource(dataSources: [loadingDataSource, remoteVersionsDataSource])
remoteVersionsLoadingDataSource.shouldFlattenSections = true
let dataSource = RSTCompositeTableViewDataSource(dataSources: [localVersionsDataSource, remoteVersionsLoadingDataSource])
dataSource.proxy = self
return dataSource
}
func update()
{
switch self.mode
{
case .restoreVersion:
self.restoreButton.title = NSLocalizedString("Restore", comment: "")
self.restoreButton.tintColor = .deltaPurple
self.restoreButton.isEnabled = (self._selectedVersionIndexPath?.section == Section.remote.rawValue)
case .resolveConflict:
self.restoreButton.title = NSLocalizedString("Resolve", comment: "")
self.restoreButton.tintColor = .red
self.restoreButton.isEnabled = (self._selectedVersionIndexPath != nil)
}
}
func fetchVersions()
{
SyncManager.shared.coordinator?.fetchVersions(for: self.record) { (result) in
do
{
let versions = try result.get().map(Version.init)
self.versions = versions
DispatchQueue.main.async {
let previousLocalVersionExists = self.tableView.numberOfRows(inSection: Section.local.rawValue) > 0
let localVersionExists = self.record.localModificationDate != nil
let localVersionIndexPath = IndexPath(row: 0, section: Section.local.rawValue)
if !previousLocalVersionExists && localVersionExists
{
self.tableView.insertRows(at: [localVersionIndexPath], with: .fade)
}
else if previousLocalVersionExists && !localVersionExists
{
self.tableView.deleteRows(at: [localVersionIndexPath], with: .fade)
}
let count = self.tableView.numberOfRows(inSection: Section.remote.rawValue)
let deletions = (0 ..< count).map { (row) -> RSTCellContentChange in
let change = RSTCellContentChange(type: .delete,
currentIndexPath: IndexPath(row: row, section: 0),
destinationIndexPath: nil)
change.rowAnimation = .fade
return change
}
let inserts = (0 ..< versions.count).map { (row) -> RSTCellContentChange in
let change = RSTCellContentChange(type: .insert,
currentIndexPath: nil,
destinationIndexPath: IndexPath(row: row, section: 0))
change.rowAnimation = .fade
return change
}
let changes = deletions + inserts
self.remoteVersionsDataSource.setItems(versions, with: changes)
}
}
catch
{
DispatchQueue.main.async {
let alertController = UIAlertController(title: NSLocalizedString("Failed to Fetch Record Versions", comment: ""), message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(.ok)
self.present(alertController, animated: true, completion: nil)
}
}
}
}
func restoreVersion()
{
guard !self.isSyncingRecord else { return }
guard let indexPath = self._selectedVersionIndexPath else { return }
guard let coordinator = SyncManager.shared.coordinator else { return }
func finish<T: Error>(_ result: Result<AnyRecord, T>)
{
DispatchQueue.main.async {
SyncManager.shared.ignoredCorruptedRecordIDs.remove(self.record.recordID)
CATransaction.begin()
CATransaction.setCompletionBlock {
self.isSyncingRecord = false
self._progressObservation = nil
self.progressView.setHidden(true, animated: true)
self.navigationItem.rightBarButtonItem?.isIndicatingActivity = false
self.update()
self.tableView.reloadData()
switch result
{
case .success: self.fetchVersions()
case .failure: break
}
}
do
{
let record = try result.get()
self.record = record
self.progressView.setProgress(1.0, animated: true)
}
catch
{
switch error
{
case RecordError.other(let record, let error as SyncValidationError):
// Only allow restoring corrupted records with incorrect games.
guard case .incorrectGame = error else { fallthrough }
let message = NSLocalizedString("Would you like to download this version anyway? Delta will try to fix the corruption if possible.", comment: "")
let alertController = UIAlertController(title: NSLocalizedString("Version Corrupted", comment: ""), message: message, preferredStyle: .alert)
alertController.addAction(.cancel)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Download Anyway", comment: ""), style: .destructive) { _ in
SyncManager.shared.ignoredCorruptedRecordIDs.insert(record.recordID)
self.restoreVersion()
})
self.present(alertController, animated: true, completion: nil)
default:
let title: String
switch self.mode
{
case .restoreVersion: title = NSLocalizedString("Failed to Restore Version", comment: "")
case .resolveConflict: title = NSLocalizedString("Failed to Resolve Conflict", comment: "")
}
let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(.ok)
self.present(alertController, animated: true, completion: nil)
}
}
CATransaction.commit()
}
}
let progress: Progress
switch (self.mode, Section.allCases[indexPath.section])
{
case (.restoreVersion, _):
let version = self.dataSource.item(at: indexPath)
progress = coordinator.restore(self.record, to: version.version) { (result) in
finish(result)
}
case (.resolveConflict, .local):
progress = coordinator.resolveConflictedRecord(self.record, resolution: .local) { (result) in
finish(result)
}
case (.resolveConflict, .remote):
let version = self.dataSource.item(at: indexPath)
progress = coordinator.resolveConflictedRecord(self.record, resolution: .remote(version.version)) { (result) in
finish(result)
}
}
self.isSyncingRecord = true
self.navigationItem.rightBarButtonItem?.isIndicatingActivity = true
self.progressView.progress = 0
self.progressView.isHidden = false
self._progressObservation = progress.observe(\.fractionCompleted) { [weak progressView] (_, change) in
DispatchQueue.main.async {
progressView?.setProgress(Float(progress.fractionCompleted), animated: true)
}
}
self.tableView.reloadData()
}
}
private extension RecordVersionsViewController
{
@IBAction func restore(_ sender: UIBarButtonItem)
{
let message: String
let actionTitle: String
switch self.mode
{
case .restoreVersion:
message = NSLocalizedString("Restoring a remote version will cause any local changes to be lost.", comment: "")
actionTitle = NSLocalizedString("Restore Version", comment: "")
case .resolveConflict:
if self._selectedVersionIndexPath?.section == Section.local.rawValue
{
message = NSLocalizedString("The local version will be uploaded and synced to your other devices.", comment: "")
}
else
{
message = NSLocalizedString("Selecting a remote version will cause any local changes to be lost.", comment: "")
}
actionTitle = NSLocalizedString("Resolve Conflict", comment: "")
}
let alertController = UIAlertController(title: nil, message: message, preferredStyle: .actionSheet)
alertController.popoverPresentationController?.barButtonItem = sender
alertController.addAction(.cancel)
alertController.addAction(UIAlertAction(title: actionTitle, style: .destructive) { (action) in
self.restoreVersion()
})
self.present(alertController, animated: true, completion: nil)
}
}
extension RecordVersionsViewController
{
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?
{
switch Section.allCases[section]
{
case .local: return NSLocalizedString("On Device", comment: "")
case .remote: return NSLocalizedString("Cloud", comment: "")
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{
tableView.deselectRow(at: indexPath, animated: true)
guard let cell = tableView.cellForRow(at: indexPath), cell.selectionStyle != .none else { return }
let indexPaths = [indexPath, self._selectedVersionIndexPath].compactMap { $0 }
self._selectedVersionIndexPath = indexPath
tableView.reloadRows(at: indexPaths, with: .none)
self.update()
}
}