Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:58:55 +01:00
parent 4af19165ec
commit 68073add76
12458 changed files with 12350765 additions and 2 deletions

View 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 = []
}
}

View 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
}
}

View 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)
}
}

View 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
}
}

View 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
}
}
}

View 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)
}
}

View 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)
}
})
}
}