Repo created
This commit is contained in:
parent
4af19165ec
commit
68073add76
12458 changed files with 12350765 additions and 2 deletions
243
iphone/Maps/Core/iCloud/CloudDirectoryMonitor.swift
Normal file
243
iphone/Maps/Core/iCloud/CloudDirectoryMonitor.swift
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
protocol CloudDirectoryMonitor: DirectoryMonitor {
|
||||
var delegate: CloudDirectoryMonitorDelegate? { get set }
|
||||
|
||||
func fetchUbiquityDirectoryUrl(completion: ((Result<URL, Error>) -> Void)?)
|
||||
func isCloudAvailable() -> Bool
|
||||
}
|
||||
|
||||
protocol CloudDirectoryMonitorDelegate : AnyObject {
|
||||
func didFinishGathering(_ contents: CloudContents)
|
||||
func didUpdate(_ contents: CloudContents, _ update: CloudContentsUpdate)
|
||||
func didReceiveCloudMonitorError(_ error: Error)
|
||||
}
|
||||
|
||||
private let kUDCloudIdentityKey = "com.apple.comaps.UbiquityIdentityToken"
|
||||
private let kDocumentsDirectoryName = "Documents"
|
||||
|
||||
final class iCloudDocumentsMonitor: NSObject, CloudDirectoryMonitor {
|
||||
|
||||
private static let sharedContainerIdentifier: String = {
|
||||
var identifier = "iCloud.app.comaps"
|
||||
#if DEBUG
|
||||
identifier.append(".debug")
|
||||
#endif
|
||||
return identifier
|
||||
}()
|
||||
|
||||
let containerIdentifier: String
|
||||
private let fileManager: FileManager
|
||||
private let fileType: FileType // TODO: Should be removed when the nested directory support will be implemented
|
||||
private var metadataQuery: NSMetadataQuery?
|
||||
private var ubiquitousDocumentsDirectory: URL?
|
||||
private var previouslyChangedContents = CloudContentsUpdate()
|
||||
|
||||
// MARK: - Public properties
|
||||
private(set) var state: DirectoryMonitorState = .stopped
|
||||
weak var delegate: CloudDirectoryMonitorDelegate?
|
||||
|
||||
init(fileManager: FileManager = .default, cloudContainerIdentifier: String = iCloudDocumentsMonitor.sharedContainerIdentifier, fileType: FileType) {
|
||||
self.fileManager = fileManager
|
||||
self.containerIdentifier = cloudContainerIdentifier
|
||||
self.fileType = fileType
|
||||
super.init()
|
||||
|
||||
fetchUbiquityDirectoryUrl()
|
||||
subscribeOnMetadataQueryNotifications()
|
||||
}
|
||||
|
||||
// MARK: - Public methods
|
||||
func start(completion: ((Result<URL, Error>) -> Void)? = nil) {
|
||||
guard isCloudAvailable() else {
|
||||
completion?(.failure(SynchronizationError.iCloudIsNotAvailable))
|
||||
return
|
||||
}
|
||||
fetchUbiquityDirectoryUrl { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
completion?(.failure(error))
|
||||
case .success(let url):
|
||||
LOG(.debug, "Start cloud monitor.")
|
||||
self.startQuery()
|
||||
self.state = .started
|
||||
completion?(.success(url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard state != .stopped else { return }
|
||||
LOG(.debug, "Stop cloud monitor.")
|
||||
stopQuery()
|
||||
state = .stopped
|
||||
previouslyChangedContents = CloudContentsUpdate()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard state != .started else { return }
|
||||
LOG(.debug, "Resume cloud monitor.")
|
||||
metadataQuery?.enableUpdates()
|
||||
state = .started
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard state != .paused else { return }
|
||||
LOG(.debug, "Pause cloud monitor.")
|
||||
metadataQuery?.disableUpdates()
|
||||
state = .paused
|
||||
}
|
||||
|
||||
func fetchUbiquityDirectoryUrl(completion: ((Result<URL, Error>) -> Void)? = nil) {
|
||||
if let ubiquitousDocumentsDirectory {
|
||||
completion?(.success(ubiquitousDocumentsDirectory))
|
||||
return
|
||||
}
|
||||
DispatchQueue.global().async {
|
||||
guard let containerUrl = self.fileManager.url(forUbiquityContainerIdentifier: self.containerIdentifier) else {
|
||||
LOG(.warning, "Failed to retrieve container's URL for:\(self.containerIdentifier)")
|
||||
completion?(.failure(SynchronizationError.containerNotFound))
|
||||
return
|
||||
}
|
||||
let documentsContainerUrl = containerUrl.appendingPathComponent(kDocumentsDirectoryName)
|
||||
if !self.fileManager.fileExists(atPath: documentsContainerUrl.path) {
|
||||
LOG(.debug, "Creating directory at path: \(documentsContainerUrl.path)...")
|
||||
do {
|
||||
try self.fileManager.createDirectory(at: documentsContainerUrl, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
completion?(.failure(SynchronizationError.containerNotFound))
|
||||
}
|
||||
}
|
||||
LOG(.debug, "Ubiquity directory URL: \(documentsContainerUrl)")
|
||||
self.ubiquitousDocumentsDirectory = documentsContainerUrl
|
||||
completion?(.success(documentsContainerUrl))
|
||||
}
|
||||
}
|
||||
|
||||
func isCloudAvailable() -> Bool {
|
||||
let cloudToken = fileManager.ubiquityIdentityToken
|
||||
guard let cloudToken else {
|
||||
UserDefaults.standard.removeObject(forKey: kUDCloudIdentityKey)
|
||||
LOG(.warning, "Cloud is not available. Cloud token is nil.")
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: cloudToken, requiringSecureCoding: true)
|
||||
UserDefaults.standard.set(data, forKey: kUDCloudIdentityKey)
|
||||
return true
|
||||
} catch {
|
||||
UserDefaults.standard.removeObject(forKey: kUDCloudIdentityKey)
|
||||
LOG(.warning, "Failed to archive cloud token: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private extension iCloudDocumentsMonitor {
|
||||
// MARK: - MetadataQuery
|
||||
func subscribeOnMetadataQueryNotifications() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(queryDidFinishGathering(_:)), name: NSNotification.Name.NSMetadataQueryDidFinishGathering, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(queryDidUpdate(_:)), name: NSNotification.Name.NSMetadataQueryDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
func startQuery() {
|
||||
metadataQuery = Self.buildMetadataQuery(for: fileType)
|
||||
guard let metadataQuery, !metadataQuery.isStarted else { return }
|
||||
LOG(.debug, "Start metadata query")
|
||||
metadataQuery.start()
|
||||
}
|
||||
|
||||
func stopQuery() {
|
||||
LOG(.debug, "Stop metadata query")
|
||||
metadataQuery?.stop()
|
||||
metadataQuery = nil
|
||||
}
|
||||
|
||||
@objc func queryDidFinishGathering(_ notification: Notification) {
|
||||
guard isCloudAvailable() else { return }
|
||||
metadataQuery?.disableUpdates()
|
||||
LOG(.debug, "Query did finish gathering")
|
||||
do {
|
||||
let currentContents = try Self.getCurrentContents(notification)
|
||||
LOG(.info, "Cloud contents (\(currentContents.count)):")
|
||||
currentContents.forEach { LOG(.info, $0.shortDebugDescription) }
|
||||
delegate?.didFinishGathering(currentContents)
|
||||
} catch {
|
||||
delegate?.didReceiveCloudMonitorError(error)
|
||||
}
|
||||
metadataQuery?.enableUpdates()
|
||||
}
|
||||
|
||||
@objc func queryDidUpdate(_ notification: Notification) {
|
||||
guard isCloudAvailable() else { return }
|
||||
metadataQuery?.disableUpdates()
|
||||
LOG(.debug, "Query did update")
|
||||
do {
|
||||
let changedContents = try Self.getChangedContents(notification)
|
||||
/* The metadataQuery can send the same changes multiple times with only uploading/downloading process updates.
|
||||
This unnecessary updated should be skipped. */
|
||||
if changedContents != previouslyChangedContents {
|
||||
previouslyChangedContents = changedContents
|
||||
let currentContents = try Self.getCurrentContents(notification)
|
||||
LOG(.info, "Cloud contents (\(currentContents.count)):")
|
||||
currentContents.forEach { LOG(.info, $0.shortDebugDescription) }
|
||||
LOG(.info, "Added to the cloud content (\(changedContents.added.count)): \n\(changedContents.added.shortDebugDescription)")
|
||||
LOG(.info, "Updated in the cloud content (\(changedContents.updated.count)): \n\(changedContents.updated.shortDebugDescription)")
|
||||
LOG(.info, "Removed from the cloud content (\(changedContents.removed.count)): \n\(changedContents.removed.shortDebugDescription)")
|
||||
delegate?.didUpdate(currentContents, changedContents)
|
||||
}
|
||||
} catch {
|
||||
delegate?.didReceiveCloudMonitorError(error)
|
||||
}
|
||||
metadataQuery?.enableUpdates()
|
||||
}
|
||||
|
||||
static func buildMetadataQuery(for fileType: FileType) -> NSMetadataQuery {
|
||||
let metadataQuery = NSMetadataQuery()
|
||||
metadataQuery.notificationBatchingInterval = 1
|
||||
metadataQuery.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
|
||||
metadataQuery.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, "*.\(fileType.fileExtension)")
|
||||
metadataQuery.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)]
|
||||
return metadataQuery
|
||||
}
|
||||
|
||||
static func getCurrentContents(_ notification: Notification) throws -> [CloudMetadataItem] {
|
||||
guard let metadataQuery = notification.object as? NSMetadataQuery,
|
||||
let metadataItems = metadataQuery.results as? [NSMetadataItem] else {
|
||||
throw SynchronizationError.failedToRetrieveMetadataQueryContent
|
||||
}
|
||||
return try metadataItems.map { try CloudMetadataItem(metadataItem: $0) }
|
||||
}
|
||||
|
||||
static func getChangedContents(_ notification: Notification) throws -> CloudContentsUpdate {
|
||||
guard let userInfo = notification.userInfo else {
|
||||
throw SynchronizationError.failedToRetrieveMetadataQueryContent
|
||||
}
|
||||
let addedMetadataItems = userInfo[NSMetadataQueryUpdateAddedItemsKey] as? [NSMetadataItem] ?? []
|
||||
let updatedMetadataItems = userInfo[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem] ?? []
|
||||
let removedMetadataItems = userInfo[NSMetadataQueryUpdateRemovedItemsKey] as? [NSMetadataItem] ?? []
|
||||
let addedContents = try addedMetadataItems.map { try CloudMetadataItem(metadataItem: $0) }
|
||||
let updatedContents = try updatedMetadataItems.map { try CloudMetadataItem(metadataItem: $0) }.filter { item in
|
||||
/* During the file deletion from the iCloud the file may be marked as `downloaded` by the system
|
||||
but doesn't exist because it is already deleted to the trash.
|
||||
This file will appear in the `deleted` list in the next notification.
|
||||
Such files should be skipped to avoid unnecessary updates and unexpected behavior.
|
||||
See https://github.com/organicmaps/organicmaps/pull/10070 for details. */
|
||||
if item.isDownloaded && !FileManager.default.fileExists(atPath: item.fileUrl.path) {
|
||||
LOG(.warning, "Skip the update of the file that doesn't exist in the file system: \(item.fileUrl)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
let removedContents = try removedMetadataItems.map { try CloudMetadataItem(metadataItem: $0) }
|
||||
return CloudContentsUpdate(added: addedContents, updated: updatedContents, removed: removedContents)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CloudContentsUpdate {
|
||||
init() {
|
||||
self.added = []
|
||||
self.updated = []
|
||||
self.removed = []
|
||||
}
|
||||
}
|
||||
220
iphone/Maps/Core/iCloud/LocalDirectoryMonitor.swift
Normal file
220
iphone/Maps/Core/iCloud/LocalDirectoryMonitor.swift
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
enum DirectoryMonitorState: CaseIterable, Equatable {
|
||||
case started
|
||||
case stopped
|
||||
case paused
|
||||
}
|
||||
|
||||
protocol DirectoryMonitor: AnyObject {
|
||||
var state: DirectoryMonitorState { get }
|
||||
|
||||
func start(completion: ((Result<URL, Error>) -> Void)?)
|
||||
func stop()
|
||||
func pause()
|
||||
func resume()
|
||||
}
|
||||
|
||||
protocol LocalDirectoryMonitor: DirectoryMonitor {
|
||||
var directory: URL { get }
|
||||
var delegate: LocalDirectoryMonitorDelegate? { get set }
|
||||
}
|
||||
|
||||
protocol LocalDirectoryMonitorDelegate : AnyObject {
|
||||
func didFinishGathering(_ contents: LocalContents)
|
||||
func didUpdate(_ contents: LocalContents, _ update: LocalContentsUpdate)
|
||||
func didReceiveLocalMonitorError(_ error: Error)
|
||||
}
|
||||
|
||||
final class FileSystemDispatchSourceMonitor: LocalDirectoryMonitor {
|
||||
|
||||
typealias Delegate = LocalDirectoryMonitorDelegate
|
||||
|
||||
fileprivate enum DispatchSourceDebounceState {
|
||||
case stopped
|
||||
case started(source: DispatchSourceFileSystemObject)
|
||||
case debounce(source: DispatchSourceFileSystemObject, timer: Timer)
|
||||
}
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let fileType: FileType
|
||||
private let resourceKeys: [URLResourceKey] = [.nameKey]
|
||||
private var dispatchSource: DispatchSourceFileSystemObject?
|
||||
private var dispatchSourceDebounceState: DispatchSourceDebounceState = .stopped
|
||||
private var dispatchSourceIsSuspended = false
|
||||
private var dispatchSourceIsResumed = false
|
||||
private var didFinishGatheringIsCalled = false
|
||||
private var contents: LocalContents = []
|
||||
|
||||
// MARK: - Public properties
|
||||
let directory: URL
|
||||
private(set) var state: DirectoryMonitorState = .stopped
|
||||
weak var delegate: Delegate?
|
||||
|
||||
init(fileManager: FileManager, directory: URL, fileType: FileType = .kml) throws {
|
||||
self.fileManager = fileManager
|
||||
self.directory = directory
|
||||
self.fileType = fileType
|
||||
if !fileManager.fileExists(atPath: directory.path) {
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public methods
|
||||
func start(completion: ((Result<URL, Error>) -> Void)? = nil) {
|
||||
guard state != .started else { return }
|
||||
|
||||
let nowTimer = Timer.scheduledTimer(withTimeInterval: .zero, repeats: false) { [weak self] _ in
|
||||
LOG(.debug, "Initial timer firing...")
|
||||
self?.debounceTimerDidFire()
|
||||
}
|
||||
|
||||
LOG(.debug, "Start local monitor.")
|
||||
if let dispatchSource {
|
||||
dispatchSourceDebounceState = .debounce(source: dispatchSource, timer: nowTimer)
|
||||
resume()
|
||||
completion?(.success(directory))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let source = try fileManager.source(for: directory)
|
||||
source.setEventHandler { [weak self] in
|
||||
self?.queueDidFire()
|
||||
}
|
||||
dispatchSourceDebounceState = .debounce(source: source, timer: nowTimer)
|
||||
source.activate()
|
||||
dispatchSource = source
|
||||
state = .started
|
||||
completion?(.success(directory))
|
||||
} catch {
|
||||
stop()
|
||||
completion?(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard state == .started else { return }
|
||||
LOG(.debug, "Stop.")
|
||||
suspendDispatchSource()
|
||||
didFinishGatheringIsCalled = false
|
||||
dispatchSourceDebounceState = .stopped
|
||||
state = .stopped
|
||||
contents.removeAll()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard state == .started else { return }
|
||||
LOG(.debug, "Pause.")
|
||||
suspendDispatchSource()
|
||||
state = .paused
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard state != .started else { return }
|
||||
LOG(.debug, "Resume.")
|
||||
resumeDispatchSource()
|
||||
state = .started
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private func queueDidFire() {
|
||||
let debounceTimeInterval = 0.5
|
||||
switch dispatchSourceDebounceState {
|
||||
case .started(let source):
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: debounceTimeInterval, repeats: false) { [weak self] _ in
|
||||
self?.debounceTimerDidFire()
|
||||
}
|
||||
dispatchSourceDebounceState = .debounce(source: source, timer: timer)
|
||||
case .debounce(_, let timer):
|
||||
timer.fireDate = Date(timeIntervalSinceNow: debounceTimeInterval)
|
||||
// Stay in the `.debounce` state.
|
||||
case .stopped:
|
||||
// This can happen if the read source fired and enqueued a block on the
|
||||
// main queue but, before the main queue got to service that block, someone
|
||||
// called `stop()`. The correct response is to just do nothing.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func debounceTimerDidFire() {
|
||||
LOG(.debug, "Debounce timer did fire.")
|
||||
guard state == .started else {
|
||||
LOG(.debug, "State is not started. Skip iteration.")
|
||||
return
|
||||
}
|
||||
guard case .debounce(let source, let timer) = dispatchSourceDebounceState else { fatalError() }
|
||||
timer.invalidate()
|
||||
dispatchSourceDebounceState = .started(source: source)
|
||||
|
||||
do {
|
||||
let files = try fileManager
|
||||
.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
|
||||
.filter { $0.pathExtension == fileType.fileExtension }
|
||||
let currentContents = try files.map { try LocalMetadataItem(fileUrl: $0) }
|
||||
didFinishGatheringIsCalled ? didUpdate(currentContents) : didFinishGathering(currentContents)
|
||||
} catch {
|
||||
delegate?.didReceiveLocalMonitorError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func didFinishGathering(_ currentContents: LocalContents) {
|
||||
didFinishGatheringIsCalled = true
|
||||
contents = currentContents
|
||||
LOG(.info, "Local contents (\(currentContents.count)):")
|
||||
currentContents.forEach { LOG(.info, $0.shortDebugDescription) }
|
||||
delegate?.didFinishGathering(currentContents)
|
||||
}
|
||||
|
||||
private func didUpdate(_ currentContents: LocalContents) {
|
||||
let changedContents = Self.getChangedContents(oldContents: contents, newContents: currentContents)
|
||||
contents = currentContents
|
||||
LOG(.info, "Local contents (\(currentContents.count)):")
|
||||
currentContents.forEach { LOG(.info, $0.shortDebugDescription) }
|
||||
LOG(.info, "Added to the local content (\(changedContents.added.count)): \n\(changedContents.added.shortDebugDescription)")
|
||||
LOG(.info, "Updated in the local content (\(changedContents.updated.count)): \n\(changedContents.updated.shortDebugDescription)")
|
||||
LOG(.info, "Removed from the local content (\(changedContents.removed.count)): \n\(changedContents.removed.shortDebugDescription)")
|
||||
delegate?.didUpdate(currentContents, changedContents)
|
||||
}
|
||||
|
||||
private static func getChangedContents(oldContents: LocalContents, newContents: LocalContents) -> LocalContentsUpdate {
|
||||
let added = newContents.filter { !oldContents.containsByName($0) }
|
||||
let updated = newContents.reduce(into: LocalContents()) { partialResult, newItem in
|
||||
if let oldItem = oldContents.firstByName(newItem), newItem.lastModificationDate > oldItem.lastModificationDate {
|
||||
partialResult.append(newItem)
|
||||
}
|
||||
}
|
||||
let removed = oldContents.filter { !newContents.containsByName($0) }
|
||||
return LocalContentsUpdate(added: added, updated: updated, removed: removed)
|
||||
}
|
||||
|
||||
private func suspendDispatchSource() {
|
||||
if !dispatchSourceIsSuspended {
|
||||
LOG(.debug, "Suspend dispatch source.")
|
||||
dispatchSource?.suspend()
|
||||
dispatchSourceIsSuspended = true
|
||||
dispatchSourceIsResumed = false
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeDispatchSource() {
|
||||
if !dispatchSourceIsResumed {
|
||||
LOG(.debug, "Resume dispatch source.")
|
||||
dispatchSource?.resume()
|
||||
dispatchSourceIsResumed = true
|
||||
dispatchSourceIsSuspended = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FileManager {
|
||||
func source(for directory: URL) throws -> DispatchSourceFileSystemObject {
|
||||
let directoryFileDescriptor = open(directory.path, O_EVTONLY)
|
||||
guard directoryFileDescriptor >= 0 else {
|
||||
throw SynchronizationError.failedToOpenLocalDirectoryFileDescriptor
|
||||
}
|
||||
let dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: directoryFileDescriptor, eventMask: [.write], queue: DispatchQueue.main)
|
||||
dispatchSource.setCancelHandler {
|
||||
close(directoryFileDescriptor)
|
||||
}
|
||||
return dispatchSource
|
||||
}
|
||||
}
|
||||
137
iphone/Maps/Core/iCloud/MetadataItem.swift
Normal file
137
iphone/Maps/Core/iCloud/MetadataItem.swift
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
protocol MetadataItem: Equatable, Hashable {
|
||||
var fileName: String { get }
|
||||
var fileUrl: URL { get }
|
||||
var lastModificationDate: TimeInterval { get }
|
||||
}
|
||||
|
||||
struct LocalMetadataItem: MetadataItem {
|
||||
let fileName: String
|
||||
let fileUrl: URL
|
||||
let lastModificationDate: TimeInterval
|
||||
}
|
||||
|
||||
struct CloudMetadataItem: MetadataItem {
|
||||
let fileName: String
|
||||
let fileUrl: URL
|
||||
var isDownloaded: Bool
|
||||
var percentDownloaded: NSNumber
|
||||
var lastModificationDate: TimeInterval
|
||||
let downloadingError: NSError?
|
||||
let uploadingError: NSError?
|
||||
let hasUnresolvedConflicts: Bool
|
||||
}
|
||||
|
||||
extension LocalMetadataItem {
|
||||
init(fileUrl: URL) throws {
|
||||
let resources = try fileUrl.resourceValues(forKeys: [.contentModificationDateKey])
|
||||
guard let lastModificationDate = resources.contentModificationDate?.roundedTime else {
|
||||
LOG(.error, "Failed to initialize LocalMetadataItem from URL's resources: \(resources)")
|
||||
throw SynchronizationError.failedToCreateMetadataItem
|
||||
}
|
||||
self.fileName = fileUrl.lastPathComponent
|
||||
self.fileUrl = fileUrl.standardizedFileURL
|
||||
self.lastModificationDate = lastModificationDate
|
||||
}
|
||||
|
||||
func fileData() throws -> Data {
|
||||
try Data(contentsOf: fileUrl)
|
||||
}
|
||||
}
|
||||
|
||||
extension CloudMetadataItem {
|
||||
init(metadataItem: NSMetadataItem) throws {
|
||||
guard let fileName = metadataItem.value(forAttribute: NSMetadataItemFSNameKey) as? String,
|
||||
let fileUrl = metadataItem.value(forAttribute: NSMetadataItemURLKey) as? URL,
|
||||
let downloadStatus = metadataItem.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String,
|
||||
let percentDownloaded = metadataItem.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? NSNumber,
|
||||
let lastModificationDate = (metadataItem.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date)?.roundedTime,
|
||||
let hasUnresolvedConflicts = metadataItem.value(forAttribute: NSMetadataUbiquitousItemHasUnresolvedConflictsKey) as? Bool else {
|
||||
let allAttributes = metadataItem.values(forAttributes: metadataItem.attributes)
|
||||
LOG(.error, "Failed to initialize CloudMetadataItem from NSMetadataItem: \(allAttributes.debugDescription)")
|
||||
throw SynchronizationError.failedToCreateMetadataItem
|
||||
}
|
||||
self.fileName = fileName
|
||||
self.fileUrl = fileUrl.standardizedFileURL
|
||||
self.isDownloaded = downloadStatus == NSMetadataUbiquitousItemDownloadingStatusCurrent
|
||||
self.percentDownloaded = percentDownloaded
|
||||
self.lastModificationDate = lastModificationDate
|
||||
self.hasUnresolvedConflicts = hasUnresolvedConflicts
|
||||
self.downloadingError = metadataItem.value(forAttribute: NSMetadataUbiquitousItemDownloadingErrorKey) as? NSError
|
||||
self.uploadingError = metadataItem.value(forAttribute: NSMetadataUbiquitousItemUploadingErrorKey) as? NSError
|
||||
}
|
||||
|
||||
init(fileUrl: URL) throws {
|
||||
let resources = try fileUrl.resourceValues(forKeys: [.nameKey,
|
||||
.contentModificationDateKey,
|
||||
.ubiquitousItemDownloadingStatusKey,
|
||||
.ubiquitousItemHasUnresolvedConflictsKey,
|
||||
.ubiquitousItemDownloadingErrorKey,
|
||||
.ubiquitousItemUploadingErrorKey])
|
||||
guard let downloadStatus = resources.ubiquitousItemDownloadingStatus,
|
||||
// Not used.
|
||||
// let percentDownloaded = resources.ubiquitousItemDownloadingStatus,
|
||||
let lastModificationDate = resources.contentModificationDate?.roundedTime,
|
||||
let hasUnresolvedConflicts = resources.ubiquitousItemHasUnresolvedConflicts else {
|
||||
LOG(.error, "Failed to initialize CloudMetadataItem from \(fileUrl) resources: \(resources.allValues)")
|
||||
throw SynchronizationError.failedToCreateMetadataItem
|
||||
}
|
||||
self.fileName = fileUrl.lastPathComponent
|
||||
self.fileUrl = fileUrl.standardizedFileURL
|
||||
let isDownloaded = downloadStatus.rawValue == NSMetadataUbiquitousItemDownloadingStatusCurrent
|
||||
self.isDownloaded = isDownloaded
|
||||
self.percentDownloaded = isDownloaded ? 0.0 : 100.0
|
||||
self.lastModificationDate = lastModificationDate
|
||||
self.hasUnresolvedConflicts = hasUnresolvedConflicts
|
||||
self.downloadingError = resources.ubiquitousItemDownloadingError
|
||||
self.uploadingError = resources.ubiquitousItemUploadingError
|
||||
}
|
||||
|
||||
func relatedLocalItemUrl(to localContainer: URL) -> URL {
|
||||
localContainer.appendingPathComponent(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
extension MetadataItem {
|
||||
var shortDebugDescription: String {
|
||||
"fileName: \(fileName), lastModified: \(lastModificationDate)"
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalMetadataItem {
|
||||
func relatedCloudItemUrl(to cloudContainer: URL) -> URL {
|
||||
cloudContainer.appendingPathComponent(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: MetadataItem {
|
||||
func containsByName(_ item: any MetadataItem) -> Bool {
|
||||
return contains(where: { $0.fileName == item.fileName })
|
||||
}
|
||||
func firstByName(_ item: any MetadataItem) -> Element? {
|
||||
return first(where: { $0.fileName == item.fileName })
|
||||
}
|
||||
|
||||
var shortDebugDescription: String {
|
||||
map { $0.shortDebugDescription }.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == CloudMetadataItem {
|
||||
var downloaded: Self {
|
||||
filter { $0.isDownloaded }
|
||||
}
|
||||
|
||||
var notDownloaded: Self {
|
||||
filter { !$0.isDownloaded && $0.percentDownloaded == 0.0 }
|
||||
}
|
||||
|
||||
func withUnresolvedConflicts(_ hasUnresolvedConflicts: Bool) -> Self {
|
||||
filter { $0.hasUnresolvedConflicts == hasUnresolvedConflicts }
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension Date {
|
||||
var roundedTime: TimeInterval {
|
||||
timeIntervalSince1970.rounded(.down)
|
||||
}
|
||||
}
|
||||
362
iphone/Maps/Core/iCloud/SynchronizaionManager.swift
Normal file
362
iphone/Maps/Core/iCloud/SynchronizaionManager.swift
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import Combine
|
||||
|
||||
enum VoidResult {
|
||||
case success
|
||||
case failure(Error)
|
||||
}
|
||||
|
||||
enum WritingResult {
|
||||
case success
|
||||
case reloadCategoriesAtURLs([URL])
|
||||
case deleteCategoriesAtURLs([URL])
|
||||
case failure(Error)
|
||||
}
|
||||
|
||||
typealias VoidResultCompletionHandler = (VoidResult) -> Void
|
||||
typealias WritingResultCompletionHandler = (WritingResult) -> Void
|
||||
|
||||
private let kBookmarksDirectoryName = "bookmarks"
|
||||
private let kICloudSynchronizationDidChangeEnabledStateNotificationName = "iCloudSynchronizationDidChangeEnabledStateNotification"
|
||||
private let kUDDidFinishInitialCloudSynchronization = "kUDDidFinishInitialCloudSynchronization"
|
||||
|
||||
final class SynchronizationManagerState: NSObject {
|
||||
let isAvailable: Bool
|
||||
let isOn: Bool
|
||||
let error: NSError?
|
||||
|
||||
init(isAvailable: Bool, isOn: Bool, error: NSError?) {
|
||||
self.isAvailable = isAvailable
|
||||
self.isOn = isOn
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
final class iCloudSynchronizaionManager: NSObject {
|
||||
|
||||
fileprivate struct Observation {
|
||||
weak var observer: AnyObject?
|
||||
var onSynchronizationStateDidChangeHandler: ((SynchronizationManagerState) -> Void)?
|
||||
}
|
||||
|
||||
var statePublisher: AnyPublisher<SynchronizationManagerState, Never> {
|
||||
stateSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let stateSubject = PassthroughSubject<SynchronizationManagerState, Never>()
|
||||
|
||||
let fileManager: FileManager
|
||||
private let localDirectoryMonitor: LocalDirectoryMonitor
|
||||
private let cloudDirectoryMonitor: CloudDirectoryMonitor
|
||||
private let settings: SettingsBridge.Type
|
||||
private let bookmarksManager: BookmarksManager
|
||||
private var synchronizationStateManager: SynchronizationStateResolver
|
||||
private var fileWriter: SynchronizationFileWriter?
|
||||
private var observers = [ObjectIdentifier: iCloudSynchronizaionManager.Observation]()
|
||||
private var synchronizationError: Error? {
|
||||
didSet { notifyObserversOnSynchronizationError(synchronizationError) }
|
||||
}
|
||||
|
||||
static private var isInitialSynchronization: Bool {
|
||||
get {
|
||||
!UserDefaults.standard.bool(forKey: kUDDidFinishInitialCloudSynchronization)
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(!newValue, forKey: kUDDidFinishInitialCloudSynchronization)
|
||||
}
|
||||
}
|
||||
|
||||
static let shared: iCloudSynchronizaionManager = {
|
||||
let fileManager = FileManager.default
|
||||
let fileType = FileType.kml
|
||||
let cloudDirectoryMonitor = iCloudDocumentsMonitor(fileManager: fileManager, fileType: fileType)
|
||||
let synchronizationStateManager = iCloudSynchronizationStateResolver(isInitialSynchronization: isInitialSynchronization)
|
||||
do {
|
||||
let localDirectoryMonitor = try FileSystemDispatchSourceMonitor(fileManager: fileManager, directory: fileManager.bookmarksDirectoryUrl, fileType: fileType)
|
||||
let clodStorageManager = iCloudSynchronizaionManager(fileManager: fileManager,
|
||||
settings: SettingsBridge.self,
|
||||
bookmarksManager: BookmarksManager.shared(),
|
||||
cloudDirectoryMonitor: cloudDirectoryMonitor,
|
||||
localDirectoryMonitor: localDirectoryMonitor,
|
||||
synchronizationStateManager: synchronizationStateManager)
|
||||
return clodStorageManager
|
||||
} catch {
|
||||
fatalError("Failed to create shared iCloud storage manager with error: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
init(fileManager: FileManager,
|
||||
settings: SettingsBridge.Type,
|
||||
bookmarksManager: BookmarksManager,
|
||||
cloudDirectoryMonitor: CloudDirectoryMonitor,
|
||||
localDirectoryMonitor: LocalDirectoryMonitor,
|
||||
synchronizationStateManager: SynchronizationStateResolver) {
|
||||
self.fileManager = fileManager
|
||||
self.settings = settings
|
||||
self.bookmarksManager = bookmarksManager
|
||||
self.cloudDirectoryMonitor = cloudDirectoryMonitor
|
||||
self.localDirectoryMonitor = localDirectoryMonitor
|
||||
self.synchronizationStateManager = synchronizationStateManager
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@objc func start() {
|
||||
subscribeToSettingsNotifications()
|
||||
subscribeToApplicationLifecycleNotifications()
|
||||
cloudDirectoryMonitor.delegate = self
|
||||
localDirectoryMonitor.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private extension iCloudSynchronizaionManager {
|
||||
// MARK: - Synchronization Lifecycle
|
||||
func startSynchronization() {
|
||||
switch cloudDirectoryMonitor.state {
|
||||
case .started:
|
||||
LOG(.debug, "Synchronization is already started")
|
||||
return
|
||||
case .paused:
|
||||
resumeSynchronization()
|
||||
case .stopped:
|
||||
cloudDirectoryMonitor.start { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
self.processError(error)
|
||||
case .success(let cloudDirectoryUrl):
|
||||
self.localDirectoryMonitor.start { result in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
self.processError(error)
|
||||
case .success(let localDirectoryUrl):
|
||||
LOG(.info, "Start synchronization")
|
||||
self.fileWriter = SynchronizationFileWriter(fileManager: self.fileManager,
|
||||
localDirectoryUrl: localDirectoryUrl,
|
||||
cloudDirectoryUrl: cloudDirectoryUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopSynchronization(withError error: Error? = nil) {
|
||||
LOG(.info, "Stop synchronization")
|
||||
localDirectoryMonitor.stop()
|
||||
cloudDirectoryMonitor.stop()
|
||||
fileWriter = nil
|
||||
synchronizationStateManager.resetState()
|
||||
|
||||
guard let error else { return }
|
||||
settings.setICLoudSynchronizationEnabled(false)
|
||||
synchronizationError = error
|
||||
MWMAlertViewController.activeAlert().presentBugReportAlert(withTitle: L("icloud_synchronization_error_alert_title"))
|
||||
}
|
||||
|
||||
func pauseSynchronization() {
|
||||
LOG(.info, "Pause synchronization")
|
||||
localDirectoryMonitor.pause()
|
||||
cloudDirectoryMonitor.pause()
|
||||
}
|
||||
|
||||
func resumeSynchronization() {
|
||||
LOG(.info, "Resume synchronization")
|
||||
localDirectoryMonitor.resume()
|
||||
cloudDirectoryMonitor.resume()
|
||||
}
|
||||
|
||||
// MARK: - App Lifecycle
|
||||
func subscribeToApplicationLifecycleNotifications() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground), name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||
}
|
||||
|
||||
func unsubscribeFromApplicationLifecycleNotifications() {
|
||||
NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||
}
|
||||
|
||||
func subscribeToSettingsNotifications() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(didChangeEnabledState), name: NSNotification.iCloudSynchronizationDidChangeEnabledState, object: nil)
|
||||
}
|
||||
|
||||
@objc func appWillEnterForeground() {
|
||||
guard settings.iCLoudSynchronizationEnabled() else { return }
|
||||
startSynchronization()
|
||||
}
|
||||
|
||||
@objc func appDidEnterBackground() {
|
||||
guard settings.iCLoudSynchronizationEnabled() else { return }
|
||||
pauseSynchronization()
|
||||
}
|
||||
|
||||
@objc func didChangeEnabledState() {
|
||||
if settings.iCLoudSynchronizationEnabled() {
|
||||
Self.isInitialSynchronization = true
|
||||
synchronizationStateManager.setInitialSynchronization(true)
|
||||
startSynchronization()
|
||||
} else {
|
||||
stopSynchronization()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iCloudStorageManger + LocalDirectoryMonitorDelegate
|
||||
extension iCloudSynchronizaionManager: LocalDirectoryMonitorDelegate {
|
||||
func didFinishGathering(_ contents: LocalContents) {
|
||||
let events = synchronizationStateManager.resolveEvent(.didFinishGatheringLocalContents(contents))
|
||||
processEvents(events)
|
||||
}
|
||||
|
||||
func didUpdate(_ contents: LocalContents, _ update: LocalContentsUpdate) {
|
||||
let events = synchronizationStateManager.resolveEvent(.didUpdateLocalContents(contents: contents, update: update))
|
||||
processEvents(events)
|
||||
}
|
||||
|
||||
func didReceiveLocalMonitorError(_ error: Error) {
|
||||
processError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iCloudStorageManger + CloudDirectoryMonitorDelegate
|
||||
extension iCloudSynchronizaionManager: CloudDirectoryMonitorDelegate {
|
||||
func didFinishGathering(_ contents: CloudContents) {
|
||||
let events = synchronizationStateManager.resolveEvent(.didFinishGatheringCloudContents(contents))
|
||||
processEvents(events)
|
||||
}
|
||||
|
||||
func didUpdate(_ contents: CloudContents, _ update: CloudContentsUpdate) {
|
||||
let events = synchronizationStateManager.resolveEvent(.didUpdateCloudContents(contents: contents, update: update))
|
||||
processEvents(events)
|
||||
}
|
||||
|
||||
func didReceiveCloudMonitorError(_ error: Error) {
|
||||
processError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private methods
|
||||
private extension iCloudSynchronizaionManager {
|
||||
func processEvents(_ events: [OutgoingSynchronizationEvent]) {
|
||||
guard !events.isEmpty else {
|
||||
synchronizationError = nil
|
||||
return
|
||||
}
|
||||
events.forEach { [weak self] event in
|
||||
guard let self, let fileWriter else { return }
|
||||
fileWriter.processEvent(event, completion: writingResultHandler(for: event))
|
||||
}
|
||||
}
|
||||
|
||||
func writingResultHandler(for event: OutgoingSynchronizationEvent) -> WritingResultCompletionHandler {
|
||||
return { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success:
|
||||
if case .didFinishInitialSynchronization = event {
|
||||
Self.isInitialSynchronization = false
|
||||
}
|
||||
case .reloadCategoriesAtURLs(let urls):
|
||||
urls.forEach { self.bookmarksManager.reloadCategory(atFilePath: $0.path) }
|
||||
case .deleteCategoriesAtURLs(let urls):
|
||||
urls.forEach { self.bookmarksManager.deleteCategory(atFilePath: $0.path) }
|
||||
case .failure(let error):
|
||||
self.processError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error handling
|
||||
func processError(_ error: Error) {
|
||||
switch error {
|
||||
case let syncError as SynchronizationError:
|
||||
switch syncError {
|
||||
case .fileUnavailable,
|
||||
.fileNotUploadedDueToQuota,
|
||||
.ubiquityServerNotAvailable:
|
||||
LOG(.warning, "Synchronization Warning: \(syncError.localizedDescription)")
|
||||
synchronizationError = syncError
|
||||
case .iCloudIsNotAvailable:
|
||||
LOG(.warning, "Synchronization Warning: \(error.localizedDescription)")
|
||||
stopSynchronization()
|
||||
case .failedToOpenLocalDirectoryFileDescriptor,
|
||||
.failedToRetrieveLocalDirectoryContent,
|
||||
.containerNotFound,
|
||||
.failedToCreateMetadataItem,
|
||||
.failedToRetrieveMetadataQueryContent:
|
||||
LOG(.error, "Synchronization Error: \(error.localizedDescription)")
|
||||
stopSynchronization(withError: error)
|
||||
}
|
||||
default:
|
||||
LOG(.error, "System Error: \(error.localizedDescription)")
|
||||
stopSynchronization(withError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observation
|
||||
protocol SynchronizationStateObservation {
|
||||
func addObserver(_ observer: AnyObject, synchronizationStateDidChangeHandler: @escaping (SynchronizationManagerState) -> Void)
|
||||
func removeObserver(_ observer: AnyObject)
|
||||
}
|
||||
|
||||
extension iCloudSynchronizaionManager {
|
||||
func addObserver(_ observer: AnyObject, synchronizationStateDidChangeHandler: @escaping (SynchronizationManagerState) -> Void) {
|
||||
let id = ObjectIdentifier(observer)
|
||||
observers[id] = Observation(observer: observer, onSynchronizationStateDidChangeHandler: synchronizationStateDidChangeHandler)
|
||||
notifyObserversOnSynchronizationError(synchronizationError)
|
||||
}
|
||||
|
||||
func removeObserver(_ observer: AnyObject) {
|
||||
let id = ObjectIdentifier(observer)
|
||||
observers.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
func notifyObservers() {
|
||||
notifyObserversOnSynchronizationError(synchronizationError)
|
||||
}
|
||||
|
||||
private func notifyObserversOnSynchronizationError(_ error: Error?) {
|
||||
let state = SynchronizationManagerState(isAvailable: cloudDirectoryMonitor.isCloudAvailable(),
|
||||
isOn: settings.iCLoudSynchronizationEnabled(),
|
||||
error: error as? NSError)
|
||||
stateSubject.send(state)
|
||||
|
||||
observers.removeUnreachable().forEach { _, observable in
|
||||
DispatchQueue.main.async {
|
||||
observable.onSynchronizationStateDidChangeHandler?(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FileManager + Directories
|
||||
extension FileManager {
|
||||
var bookmarksDirectoryUrl: URL {
|
||||
urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(kBookmarksDirectoryName, isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification + iCloudSynchronizationDidChangeEnabledState
|
||||
extension Notification.Name {
|
||||
static let iCloudSynchronizationDidChangeEnabledStateNotification = Notification.Name(kICloudSynchronizationDidChangeEnabledStateNotificationName)
|
||||
}
|
||||
|
||||
@objc extension NSNotification {
|
||||
public static let iCloudSynchronizationDidChangeEnabledState = Notification.Name.iCloudSynchronizationDidChangeEnabledStateNotification
|
||||
}
|
||||
|
||||
// MARK: - Dictionary + RemoveUnreachable
|
||||
private extension Dictionary where Key == ObjectIdentifier, Value == iCloudSynchronizaionManager.Observation {
|
||||
mutating func removeUnreachable() -> Self {
|
||||
for (id, observation) in self {
|
||||
if observation.observer == nil {
|
||||
removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
51
iphone/Maps/Core/iCloud/SynchronizationError.swift
Normal file
51
iphone/Maps/Core/iCloud/SynchronizationError.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
@objc enum SynchronizationError: Int, Error {
|
||||
case fileUnavailable
|
||||
case fileNotUploadedDueToQuota
|
||||
case ubiquityServerNotAvailable
|
||||
case iCloudIsNotAvailable
|
||||
case containerNotFound
|
||||
case failedToOpenLocalDirectoryFileDescriptor
|
||||
case failedToRetrieveLocalDirectoryContent
|
||||
case failedToCreateMetadataItem
|
||||
case failedToRetrieveMetadataQueryContent
|
||||
}
|
||||
|
||||
extension SynchronizationError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .fileUnavailable, .ubiquityServerNotAvailable:
|
||||
return L("icloud_synchronization_error_connection_error")
|
||||
case .fileNotUploadedDueToQuota:
|
||||
return L("icloud_synchronization_error_quota_exceeded")
|
||||
case .iCloudIsNotAvailable, .containerNotFound:
|
||||
return L("icloud_synchronization_error_cloud_is_unavailable")
|
||||
case .failedToOpenLocalDirectoryFileDescriptor:
|
||||
return "Failed to open local directory file descriptor"
|
||||
case .failedToRetrieveLocalDirectoryContent:
|
||||
return "Failed to retrieve local directory content"
|
||||
case .failedToCreateMetadataItem:
|
||||
return "Failed to create metadata item."
|
||||
case .failedToRetrieveMetadataQueryContent:
|
||||
return "Failed to retrieve NSMetadataQuery content."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Error {
|
||||
var ubiquitousError: SynchronizationError? {
|
||||
let nsError = self as NSError
|
||||
switch nsError.code {
|
||||
// NSURLUbiquitousItemDownloadingErrorKey contains an error with this code when the item has not been uploaded to iCloud by the other devices yet
|
||||
case NSUbiquitousFileUnavailableError:
|
||||
return .fileUnavailable
|
||||
// NSURLUbiquitousItemUploadingErrorKey contains an error with this code when the item has not been uploaded to iCloud because it would make the account go over-quota
|
||||
case NSUbiquitousFileNotUploadedDueToQuotaError:
|
||||
return .fileNotUploadedDueToQuota
|
||||
// NSURLUbiquitousItemDownloadingErrorKey and NSURLUbiquitousItemUploadingErrorKey contain an error with this code when connecting to the iCloud servers failed
|
||||
case NSUbiquitousFileUbiquityServerNotAvailable:
|
||||
return .ubiquityServerNotAvailable
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
277
iphone/Maps/Core/iCloud/SynchronizationFileWriter.swift
Normal file
277
iphone/Maps/Core/iCloud/SynchronizationFileWriter.swift
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
final class SynchronizationFileWriter {
|
||||
private let fileManager: FileManager
|
||||
private let backgroundQueue = DispatchQueue(label: "iCloud.app.comaps.backgroundQueue", qos: .background)
|
||||
private let fileCoordinator: NSFileCoordinator
|
||||
private let localDirectoryUrl: URL
|
||||
private let cloudDirectoryUrl: URL
|
||||
|
||||
init(fileManager: FileManager = .default,
|
||||
fileCoordinator: NSFileCoordinator = NSFileCoordinator(),
|
||||
localDirectoryUrl: URL,
|
||||
cloudDirectoryUrl: URL) {
|
||||
self.fileManager = fileManager
|
||||
self.fileCoordinator = fileCoordinator
|
||||
self.localDirectoryUrl = localDirectoryUrl
|
||||
self.cloudDirectoryUrl = cloudDirectoryUrl
|
||||
}
|
||||
|
||||
func processEvent(_ event: OutgoingSynchronizationEvent, completion: @escaping WritingResultCompletionHandler) {
|
||||
let resultCompletion: WritingResultCompletionHandler = { result in
|
||||
DispatchQueue.main.sync { completion(result) }
|
||||
}
|
||||
backgroundQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
switch event {
|
||||
case .createLocalItem(let cloudMetadataItem): self.createInLocalContainer(cloudMetadataItem, completion: resultCompletion)
|
||||
case .updateLocalItem(let cloudMetadataItem): self.updateInLocalContainer(cloudMetadataItem, completion: resultCompletion)
|
||||
case .removeLocalItem(let localMetadataItem): self.removeFromLocalContainer(localMetadataItem, completion: resultCompletion)
|
||||
case .startDownloading(let cloudMetadataItem): self.startDownloading(cloudMetadataItem, completion: resultCompletion)
|
||||
case .createCloudItem(let localMetadataItem): self.createInCloudContainer(localMetadataItem, completion: resultCompletion)
|
||||
case .updateCloudItem(let localMetadataItem): self.updateInCloudContainer(localMetadataItem, completion: resultCompletion)
|
||||
case .removeCloudItem(let cloudMetadataItem): self.removeFromCloudContainer(cloudMetadataItem, completion: resultCompletion)
|
||||
case .resolveVersionsConflict(let cloudMetadataItem): self.resolveVersionsConflict(cloudMetadataItem, completion: resultCompletion)
|
||||
case .resolveInitialSynchronizationConflict(let localMetadataItem): self.resolveInitialSynchronizationConflict(localMetadataItem, completion: resultCompletion)
|
||||
case .didFinishInitialSynchronization: resultCompletion(.success)
|
||||
case .didReceiveError(let error): resultCompletion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read/Write/Downloading/Uploading
|
||||
private func startDownloading(_ cloudMetadataItem: CloudMetadataItem, completion: WritingResultCompletionHandler) {
|
||||
LOG(.info, "Start downloading file: \(cloudMetadataItem.fileUrl.path)...")
|
||||
do {
|
||||
if fileManager.isUbiquitousItem(at: cloudMetadataItem.fileUrl) {
|
||||
try fileManager.startDownloadingUbiquitousItem(at: cloudMetadataItem.fileUrl)
|
||||
} else {
|
||||
LOG(.warning, "File \(cloudMetadataItem.fileUrl.path) is not a ubiquitous item. Skipping download.")
|
||||
}
|
||||
completion(.success)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func createInLocalContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
let targetLocalFileUrl = cloudMetadataItem.relatedLocalItemUrl(to: localDirectoryUrl)
|
||||
guard !fileManager.fileExists(atPath: targetLocalFileUrl.path) else {
|
||||
LOG(.info, "File \(cloudMetadataItem.fileName) already exists in the local iCloud container.")
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
writeToLocalContainer(cloudMetadataItem, completion: completion)
|
||||
}
|
||||
|
||||
private func updateInLocalContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
writeToLocalContainer(cloudMetadataItem, completion: completion)
|
||||
}
|
||||
|
||||
private func writeToLocalContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
LOG(.info, "Write file \(cloudMetadataItem.fileName) to the local directory")
|
||||
var coordinationError: NSError?
|
||||
let targetLocalFileUrl = cloudMetadataItem.relatedLocalItemUrl(to: localDirectoryUrl)
|
||||
fileCoordinator.coordinate(readingItemAt: cloudMetadataItem.fileUrl, writingItemAt: targetLocalFileUrl, error: &coordinationError) { readingUrl, writingUrl in
|
||||
do {
|
||||
/* During the synchronization process, when the file in trashed by iCloud,
|
||||
the notification still can contain the already deleted file in the `updated` list instead of `deleted`.
|
||||
In this case, the file replacement should be skipped. */
|
||||
guard fileManager.fileExists(atPath: readingUrl.path) else {
|
||||
LOG(.error, "iCloud file \(readingUrl.lastPathComponent) doesn't exist.")
|
||||
completion(.failure(SynchronizationError.fileUnavailable))
|
||||
return
|
||||
}
|
||||
try fileManager.replaceFileSafe(at: writingUrl, with: readingUrl)
|
||||
LOG(.debug, "File \(cloudMetadataItem.fileName) is copied to local directory successfully. Start reloading bookmarks...")
|
||||
completion(.reloadCategoriesAtURLs([writingUrl]))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
if let coordinationError {
|
||||
completion(.failure(coordinationError))
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLocalContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
LOG(.info, "Remove file \(localMetadataItem.fileName) from the local directory")
|
||||
let targetLocalFileUrl = localMetadataItem.fileUrl
|
||||
guard fileManager.fileExists(atPath: targetLocalFileUrl.path) else {
|
||||
LOG(.warning, "File \(localMetadataItem.fileName) doesn't exist in the local directory and cannot be removed")
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
completion(.deleteCategoriesAtURLs([targetLocalFileUrl]))
|
||||
}
|
||||
|
||||
private func createInCloudContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
let targetCloudFileUrl = localMetadataItem.relatedCloudItemUrl(to: cloudDirectoryUrl)
|
||||
guard !fileManager.fileExists(atPath: targetCloudFileUrl.path) else {
|
||||
LOG(.info, "File \(localMetadataItem.fileName) already exists in the cloud directory")
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
writeToCloudContainer(localMetadataItem, completion: completion)
|
||||
}
|
||||
|
||||
private func updateInCloudContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
writeToCloudContainer(localMetadataItem, completion: completion)
|
||||
}
|
||||
|
||||
private func writeToCloudContainer(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
LOG(.info, "Write file \(localMetadataItem.fileName) to the cloud directory")
|
||||
let targetCloudFileUrl = localMetadataItem.relatedCloudItemUrl(to: cloudDirectoryUrl)
|
||||
var coordinationError: NSError?
|
||||
fileCoordinator.coordinate(readingItemAt: localMetadataItem.fileUrl, writingItemAt: targetCloudFileUrl, error: &coordinationError) { readingUrl, writingUrl in
|
||||
do {
|
||||
try fileManager.replaceFileSafe(at: writingUrl, with: readingUrl)
|
||||
LOG(.debug, "File \(localMetadataItem.fileName) is copied to the cloud directory successfully")
|
||||
completion(.success)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
if let coordinationError {
|
||||
completion(.failure(coordinationError))
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromCloudContainer(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
LOG(.info, "Trash file \(cloudMetadataItem.fileName) to the iCloud trash")
|
||||
let targetCloudFileUrl = cloudMetadataItem.fileUrl
|
||||
guard fileManager.fileExists(atPath: targetCloudFileUrl.path) else {
|
||||
LOG(.warning, "File \(cloudMetadataItem.fileName) doesn't exist in the cloud directory and cannot be moved to the trash")
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try fileManager.trashItem(at: targetCloudFileUrl, resultingItemURL: nil)
|
||||
completion(.success)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Merge conflicts resolving
|
||||
private func resolveVersionsConflict(_ cloudMetadataItem: CloudMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
LOG(.info, "Start resolving version conflict for file \(cloudMetadataItem.fileName)...")
|
||||
|
||||
guard let versionsInConflict = NSFileVersion.unresolvedConflictVersionsOfItem(at: cloudMetadataItem.fileUrl), !versionsInConflict.isEmpty,
|
||||
let currentVersion = NSFileVersion.currentVersionOfItem(at: cloudMetadataItem.fileUrl) else {
|
||||
LOG(.info, "No versions in conflict found for file \(cloudMetadataItem.fileName).")
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
|
||||
let sortedVersions = versionsInConflict.sorted { version1, version2 in
|
||||
guard let date1 = version1.modificationDate, let date2 = version2.modificationDate else {
|
||||
return false
|
||||
}
|
||||
return date1 > date2
|
||||
}
|
||||
|
||||
guard let latestVersionInConflict = sortedVersions.first else {
|
||||
LOG(.info, "No latest version in conflict found for file \(cloudMetadataItem.fileName).")
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
|
||||
let targetCloudFileCopyUrl = generateNewFileUrl(for: cloudMetadataItem.fileUrl)
|
||||
var coordinationError: NSError?
|
||||
fileCoordinator.coordinate(writingItemAt: currentVersion.url,
|
||||
options: [.forReplacing],
|
||||
writingItemAt: targetCloudFileCopyUrl,
|
||||
options: [],
|
||||
error: &coordinationError) { currentVersionUrl, copyVersionUrl in
|
||||
// Check that during the coordination block, the current version of the file have not been already resolved by another process.
|
||||
guard let unresolvedVersions = NSFileVersion.unresolvedConflictVersionsOfItem(at: currentVersionUrl), !unresolvedVersions.isEmpty else {
|
||||
LOG(.info, "File \(cloudMetadataItem.fileName) was already resolved.")
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
do {
|
||||
// Check if the file was already resolved by another process. The in-memory versions should be marked as resolved.
|
||||
guard !fileManager.fileExists(atPath: copyVersionUrl.path) else {
|
||||
LOG(.info, "File \(cloudMetadataItem.fileName) was already resolved.")
|
||||
try NSFileVersion.removeOtherVersionsOfItem(at: currentVersionUrl)
|
||||
completion(.success)
|
||||
return
|
||||
}
|
||||
|
||||
LOG(.info, "Duplicate file \(cloudMetadataItem.fileName)...")
|
||||
try latestVersionInConflict.replaceItem(at: copyVersionUrl)
|
||||
// The modification date should be updated to mark files that was involved into the resolving process.
|
||||
try currentVersionUrl.setResourceModificationDate(Date())
|
||||
try copyVersionUrl.setResourceModificationDate(Date())
|
||||
unresolvedVersions.forEach { $0.isResolved = true }
|
||||
try NSFileVersion.removeOtherVersionsOfItem(at: currentVersionUrl)
|
||||
LOG(.info, "File \(cloudMetadataItem.fileName) was successfully resolved.")
|
||||
completion(.success)
|
||||
return
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let coordinationError {
|
||||
completion(.failure(coordinationError))
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveInitialSynchronizationConflict(_ localMetadataItem: LocalMetadataItem, completion: @escaping WritingResultCompletionHandler) {
|
||||
LOG(.info, "Start resolving initial sync conflict for file \(localMetadataItem.fileName) by copying with a new name...")
|
||||
do {
|
||||
let newFileUrl = generateNewFileUrl(for: localMetadataItem.fileUrl, addDeviceName: true)
|
||||
if !fileManager.fileExists(atPath: newFileUrl.path) {
|
||||
try fileManager.copyItem(at: localMetadataItem.fileUrl, to: newFileUrl)
|
||||
} else {
|
||||
try fileManager.replaceFileSafe(at: newFileUrl, with: localMetadataItem.fileUrl)
|
||||
}
|
||||
LOG(.info, "File \(localMetadataItem.fileName) was successfully resolved.")
|
||||
completion(.reloadCategoriesAtURLs([newFileUrl]))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper methods
|
||||
// Generate a new file URL with a new name for the file with the same name.
|
||||
// This method should generate the same name for the same file on different devices during the simultaneous conflict resolving.
|
||||
private func generateNewFileUrl(for fileUrl: URL, addDeviceName: Bool = false) -> URL {
|
||||
let baseName = fileUrl.deletingPathExtension().lastPathComponent
|
||||
let fileExtension = fileUrl.pathExtension
|
||||
let newBaseName = baseName + "_1"
|
||||
let deviceName = addDeviceName ? "_\(UIDevice.current.name)" : ""
|
||||
let newFileName = newBaseName + deviceName + "." + fileExtension
|
||||
let newFileUrl = fileUrl.deletingLastPathComponent().appendingPathComponent(newFileName)
|
||||
return newFileUrl
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FileManager + FileReplacing
|
||||
private extension FileManager {
|
||||
func replaceFileSafe(at targetUrl: URL, with sourceUrl: URL) throws {
|
||||
guard fileExists(atPath: targetUrl.path) else {
|
||||
LOG(.info, "Target file \(targetUrl.lastPathComponent) doesn't exist. The file will be copied.")
|
||||
try copyItem(at: sourceUrl, to: targetUrl)
|
||||
return
|
||||
}
|
||||
let tmpDirectoryUrl = try url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: targetUrl, create: true)
|
||||
let tmpUrl = tmpDirectoryUrl.appendingPathComponent(sourceUrl.lastPathComponent)
|
||||
try copyItem(at: sourceUrl, to: tmpUrl)
|
||||
try replaceItem(at: targetUrl, withItemAt: tmpUrl, backupItemName: nil, options: [.usingNewMetadataOnly], resultingItemURL: nil)
|
||||
LOG(.debug, "File \(targetUrl.lastPathComponent) was replaced successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL + ResourceValues
|
||||
private extension URL {
|
||||
func setResourceModificationDate(_ date: Date) throws {
|
||||
var url = self
|
||||
var resource = try resourceValues(forKeys:[.contentModificationDateKey])
|
||||
resource.contentModificationDate = date
|
||||
try url.setResourceValues(resource)
|
||||
}
|
||||
}
|
||||
228
iphone/Maps/Core/iCloud/SynchronizationStateResolver.swift
Normal file
228
iphone/Maps/Core/iCloud/SynchronizationStateResolver.swift
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
typealias LocalContents = [LocalMetadataItem]
|
||||
typealias CloudContents = [CloudMetadataItem]
|
||||
typealias LocalContentsUpdate = ContentsUpdate<LocalMetadataItem>
|
||||
typealias CloudContentsUpdate = ContentsUpdate<CloudMetadataItem>
|
||||
|
||||
struct ContentsUpdate<T: MetadataItem>: Equatable {
|
||||
let added: [T]
|
||||
let updated: [T]
|
||||
let removed: [T]
|
||||
}
|
||||
|
||||
protocol SynchronizationStateResolver {
|
||||
func setInitialSynchronization(_ isInitialSynchronization: Bool)
|
||||
func resolveEvent(_ event: IncomingSynchronizationEvent) -> [OutgoingSynchronizationEvent]
|
||||
func resetState()
|
||||
}
|
||||
|
||||
enum IncomingSynchronizationEvent {
|
||||
case didFinishGatheringLocalContents(LocalContents)
|
||||
case didFinishGatheringCloudContents(CloudContents)
|
||||
case didUpdateLocalContents(contents: LocalContents, update: LocalContentsUpdate)
|
||||
case didUpdateCloudContents(contents: CloudContents, update: CloudContentsUpdate)
|
||||
}
|
||||
|
||||
enum OutgoingSynchronizationEvent: Equatable {
|
||||
case startDownloading(CloudMetadataItem)
|
||||
|
||||
case createLocalItem(with: CloudMetadataItem)
|
||||
case updateLocalItem(with: CloudMetadataItem)
|
||||
case removeLocalItem(LocalMetadataItem)
|
||||
|
||||
case createCloudItem(with: LocalMetadataItem)
|
||||
case updateCloudItem(with: LocalMetadataItem)
|
||||
case removeCloudItem(CloudMetadataItem)
|
||||
|
||||
case didReceiveError(SynchronizationError)
|
||||
case resolveVersionsConflict(CloudMetadataItem)
|
||||
case resolveInitialSynchronizationConflict(LocalMetadataItem)
|
||||
case didFinishInitialSynchronization
|
||||
}
|
||||
|
||||
final class iCloudSynchronizationStateResolver: SynchronizationStateResolver {
|
||||
|
||||
// MARK: - Public properties
|
||||
private var localContentsGatheringIsFinished = false
|
||||
private var cloudContentGatheringIsFinished = false
|
||||
private var currentLocalContents: LocalContents = []
|
||||
private var currentCloudContents: CloudContents = []
|
||||
private var isInitialSynchronization: Bool
|
||||
|
||||
init(isInitialSynchronization: Bool) {
|
||||
self.isInitialSynchronization = isInitialSynchronization
|
||||
}
|
||||
|
||||
// MARK: - Public methods
|
||||
@discardableResult
|
||||
func resolveEvent(_ event: IncomingSynchronizationEvent) -> [OutgoingSynchronizationEvent] {
|
||||
let outgoingEvents: [OutgoingSynchronizationEvent]
|
||||
switch event {
|
||||
case .didFinishGatheringLocalContents(let contents):
|
||||
localContentsGatheringIsFinished = true
|
||||
outgoingEvents = resolveDidFinishGathering(localContents: contents, cloudContents: currentCloudContents)
|
||||
case .didFinishGatheringCloudContents(let contents):
|
||||
cloudContentGatheringIsFinished = true
|
||||
outgoingEvents = resolveDidFinishGathering(localContents: currentLocalContents, cloudContents: contents)
|
||||
case .didUpdateLocalContents(let contents, let update):
|
||||
currentLocalContents = contents
|
||||
outgoingEvents = resolveDidUpdateLocalContents(update)
|
||||
case .didUpdateCloudContents(let contents, let update):
|
||||
currentCloudContents = contents
|
||||
outgoingEvents = resolveDidUpdateCloudContents(update)
|
||||
}
|
||||
|
||||
LOG(.info, "Events to process (\(outgoingEvents.count)):")
|
||||
outgoingEvents.forEach { LOG(.info, $0) }
|
||||
|
||||
return outgoingEvents
|
||||
}
|
||||
|
||||
func resetState() {
|
||||
LOG(.debug, "Resetting state")
|
||||
currentLocalContents.removeAll()
|
||||
currentCloudContents.removeAll()
|
||||
localContentsGatheringIsFinished = false
|
||||
cloudContentGatheringIsFinished = false
|
||||
}
|
||||
|
||||
func setInitialSynchronization(_ isInitialSynchronization: Bool) {
|
||||
self.isInitialSynchronization = isInitialSynchronization
|
||||
}
|
||||
|
||||
private func resolveDidFinishGathering(localContents: LocalContents, cloudContents: CloudContents) -> [OutgoingSynchronizationEvent] {
|
||||
currentLocalContents = localContents
|
||||
currentCloudContents = cloudContents
|
||||
guard localContentsGatheringIsFinished, cloudContentGatheringIsFinished else { return [] }
|
||||
|
||||
switch (localContents.isEmpty, cloudContents.isEmpty) {
|
||||
case (true, true):
|
||||
return []
|
||||
case (true, false):
|
||||
return cloudContents.map { .createLocalItem(with: $0) }
|
||||
case (false, true):
|
||||
return localContents.map { .createCloudItem(with: $0) }
|
||||
case (false, false):
|
||||
var events = [OutgoingSynchronizationEvent]()
|
||||
if isInitialSynchronization {
|
||||
/* During the initial synchronization:
|
||||
- all conflicted local and cloud items will be saved to avoid a data loss
|
||||
- all items that are in the cloud but not in the local container will be created in the local container
|
||||
- all items that are in the local container but not in the cloud container will be created in the cloud container
|
||||
*/
|
||||
localContents.forEach { localItem in
|
||||
if let cloudItem = cloudContents.downloaded.firstByName(localItem), localItem.lastModificationDate != cloudItem.lastModificationDate {
|
||||
if cloudItem.isDownloaded {
|
||||
events.append(.resolveInitialSynchronizationConflict(localItem))
|
||||
events.append(.updateLocalItem(with: cloudItem))
|
||||
} else {
|
||||
events.append(.startDownloading(cloudItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let itemsToCreateInCloudContainer = localContents.filter { !cloudContents.containsByName($0) }
|
||||
let itemsToCreateInLocalContainer = cloudContents.filter { !localContents.containsByName($0) }
|
||||
itemsToCreateInLocalContainer.notDownloaded.forEach { events.append(.startDownloading($0)) }
|
||||
itemsToCreateInLocalContainer.downloaded.forEach { events.append(.createLocalItem(with: $0)) }
|
||||
itemsToCreateInCloudContainer.forEach { events.append(.createCloudItem(with: $0)) }
|
||||
|
||||
events.append(.didFinishInitialSynchronization)
|
||||
isInitialSynchronization = false
|
||||
} else {
|
||||
cloudContents.getErrors.forEach { events.append(.didReceiveError($0)) }
|
||||
cloudContents.withUnresolvedConflicts.forEach { events.append(.resolveVersionsConflict($0)) }
|
||||
|
||||
/* During the non-initial synchronization:
|
||||
- the iCloud container is considered as the source of truth and all items that are changed
|
||||
in the cloud container will be updated in the local container
|
||||
- itemsToCreateInCloudContainer is not present here because the new files cannot be added locally
|
||||
when the app is closed and only the cloud contents can be changed (added/removed) between app launches
|
||||
*/
|
||||
let itemsToRemoveFromLocalContainer = localContents.filter { !cloudContents.containsByName($0) }
|
||||
let itemsToCreateInLocalContainer = cloudContents.filter { !localContents.containsByName($0) }
|
||||
let itemsToUpdateInLocalContainer = cloudContents.filter { cloudItem in
|
||||
guard let localItem = localContents.firstByName(cloudItem) else { return false }
|
||||
return cloudItem.lastModificationDate > localItem.lastModificationDate
|
||||
}
|
||||
let itemsToUpdateInCloudContainer = localContents.filter { localItem in
|
||||
guard let cloudItem = cloudContents.firstByName(localItem) else { return false }
|
||||
return localItem.lastModificationDate > cloudItem.lastModificationDate
|
||||
}
|
||||
|
||||
itemsToCreateInLocalContainer.notDownloaded.forEach { events.append(.startDownloading($0)) }
|
||||
itemsToUpdateInLocalContainer.notDownloaded.forEach { events.append(.startDownloading($0)) }
|
||||
|
||||
itemsToRemoveFromLocalContainer.forEach { events.append(.removeLocalItem($0)) }
|
||||
itemsToCreateInLocalContainer.downloaded.forEach { events.append(.createLocalItem(with: $0)) }
|
||||
itemsToUpdateInLocalContainer.downloaded.forEach { events.append(.updateLocalItem(with: $0)) }
|
||||
itemsToUpdateInCloudContainer.forEach { events.append(.updateCloudItem(with: $0)) }
|
||||
}
|
||||
return events
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDidUpdateLocalContents(_ localContents: LocalContentsUpdate) -> [OutgoingSynchronizationEvent] {
|
||||
var outgoingEvents = [OutgoingSynchronizationEvent]()
|
||||
localContents.removed.forEach { localItem in
|
||||
guard let cloudItem = self.currentCloudContents.firstByName(localItem) else { return }
|
||||
outgoingEvents.append(.removeCloudItem(cloudItem))
|
||||
}
|
||||
localContents.added.forEach { localItem in
|
||||
guard !self.currentCloudContents.containsByName(localItem) else { return }
|
||||
outgoingEvents.append(.createCloudItem(with: localItem))
|
||||
}
|
||||
localContents.updated.forEach { localItem in
|
||||
guard let cloudItem = self.currentCloudContents.firstByName(localItem) else {
|
||||
outgoingEvents.append(.createCloudItem(with: localItem))
|
||||
return
|
||||
}
|
||||
guard localItem.lastModificationDate > cloudItem.lastModificationDate else { return }
|
||||
outgoingEvents.append(.updateCloudItem(with: localItem))
|
||||
}
|
||||
return outgoingEvents
|
||||
}
|
||||
|
||||
private func resolveDidUpdateCloudContents(_ cloudContents: CloudContentsUpdate) -> [OutgoingSynchronizationEvent] {
|
||||
var outgoingEvents = [OutgoingSynchronizationEvent]()
|
||||
currentCloudContents.getErrors.forEach { outgoingEvents.append(.didReceiveError($0)) }
|
||||
currentCloudContents.withUnresolvedConflicts.forEach { outgoingEvents.append(.resolveVersionsConflict($0)) }
|
||||
|
||||
cloudContents.added.notDownloaded.forEach { outgoingEvents.append(.startDownloading($0)) }
|
||||
cloudContents.updated.notDownloaded.forEach { outgoingEvents.append(.startDownloading($0)) }
|
||||
|
||||
cloudContents.removed.forEach { cloudItem in
|
||||
guard let localItem = self.currentLocalContents.firstByName(cloudItem) else { return }
|
||||
outgoingEvents.append(.removeLocalItem(localItem))
|
||||
}
|
||||
cloudContents.added.downloaded.forEach { cloudItem in
|
||||
guard !self.currentLocalContents.containsByName(cloudItem) else { return }
|
||||
outgoingEvents.append(.createLocalItem(with: cloudItem))
|
||||
}
|
||||
cloudContents.updated.downloaded.forEach { cloudItem in
|
||||
guard let localItem = self.currentLocalContents.firstByName(cloudItem) else {
|
||||
outgoingEvents.append(.createLocalItem(with: cloudItem))
|
||||
return
|
||||
}
|
||||
guard cloudItem.lastModificationDate > localItem.lastModificationDate else { return }
|
||||
outgoingEvents.append(.updateLocalItem(with: cloudItem))
|
||||
}
|
||||
return outgoingEvents
|
||||
}
|
||||
}
|
||||
|
||||
private extension CloudContents {
|
||||
var withUnresolvedConflicts: CloudContents {
|
||||
filter { $0.hasUnresolvedConflicts }
|
||||
}
|
||||
|
||||
var getErrors: [SynchronizationError] {
|
||||
reduce(into: [SynchronizationError](), { partialResult, cloudItem in
|
||||
if let downloadingError = cloudItem.downloadingError, let synchronizationError = downloadingError.ubiquitousError {
|
||||
partialResult.append(synchronizationError)
|
||||
}
|
||||
if let uploadingError = cloudItem.uploadingError, let synchronizationError = uploadingError.ubiquitousError {
|
||||
partialResult.append(synchronizationError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue