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.
428 lines
17 KiB
Swift
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()
|
|
}
|
|
}
|