Skip to content

Commit

Permalink
Add code and tests
Browse files Browse the repository at this point in the history
- Copy and update working code from "Starfish - Get Things Done".
- Add unit tests to test the error conditions we use and recommend.
  • Loading branch information
ggruen committed Sep 23, 2020
1 parent d221153 commit c571e95
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 29 deletions.
12 changes: 6 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
import PackageDescription

let package = Package(
name: "NSPersistentCloudKitContainerSyncStatus",
name: "CloudKitSyncStatus",
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "NSPersistentCloudKitContainerSyncStatus",
targets: ["NSPersistentCloudKitContainerSyncStatus"]),
name: "CloudKitSyncStatus",
targets: ["CloudKitSyncStatus"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
Expand All @@ -19,10 +19,10 @@ let package = Package(
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "NSPersistentCloudKitContainerSyncStatus",
name: "CloudKitSyncStatus",
dependencies: []),
.testTarget(
name: "NSPersistentCloudKitContainerSyncStatusTests",
dependencies: ["NSPersistentCloudKitContainerSyncStatus"]),
name: "CloudKitSyncStatusTests",
dependencies: ["CloudKitSyncStatus"]),
]
)
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,66 @@
# NSPersistentCloudKitContainerSyncStatus
# CloudKitSyncStatus

A description of this package.
`CloudKitSyncStatus` listens to the notifications sent out by `NSPersistentCloudKitContainer`
and translates them into a few published properties that can give your app a current state of its sync.

The primary use for this is to detect that rare condition in which CloudKit (and therefore your app) will just stop syncing with no warning and
no notification to the user. Well, now there's an immediate warning, and you can notify the user.

This SwiftUI view will display a red error image at the top of the screen if there's an import or export error:

import CloudKitSyncStatus
struct SyncStatusView: View {
@available(iOS 14.0, *)
@ObservedObject var syncStatus = SyncStatus.shared

var body: some View {
// Report only on real sync errors
if #available(iOS 14.0, *), (syncStatus.importError || syncStatus.exportError) {
VStack {
HStack {
if syncStatus.importError {
Image(systemName: "icloud.and.arrow.down").foregroundColor(.red)
}
if syncStatus.exportError {
Image(systemName: "icloud.and.arrow.up").foregroundColor(.red)
}
}
Spacer()
}
}
}
}

`CloudKitSyncStatus` has a few "magic" properties, which are featured in the example above, and are what you
really should use. Avoid the temptation to offer a continuous "sync status", and _absolutely_ avoid the temptation to detect when "sync is
finished", as in a distributed environment (such as the one your app is creating when you use `NSPersistentCloudKitContainer`), sync is
never "finished", and you're asking for "bad things", "unpredictable results", etc if you attempt to detect "sync is finished".

Anyway, the following properties take the state of the network into account and only say there's an error if there's an active network
connection _and_ `NSPersistentCloudKitContainer` says an import or export failed:

- `syncError`, which tells you that something has gone wrong when nothing should be going wrong
- `importError`, which tells you that the last import failed when it shouldn't have
- `exportError`, which tells you that the last export failed when it shouldn't have

Detecting these conditions is important because the usual "fix" for CloudKit not syncing is to delete the local database. This is fine if your
import stopped working, but if the export stopped working, this means that your user will lose any changes they made between the time the
sync failed and when it was detected. Previously, that time was based on when the user looked at two devices and noticed that they didn't
contain the same data. With `CloudKitSyncStatus`, your app can report (or act on) that failure _immediately_, saving your user's data and
your app's reputation.

# Installation

`CloudKitSyncStatus` is a swift package - add it to `Package.swift`:

dependencies: [
.package(url: "https://github.com/ggruen/CloudKitSyncStatus.git", from: "1.0.0"),
],
targets: [
.target(
name: "MyApp", // Where "MyApp" is the name of your app
dependencies: ["SocketConnection"]),
]

Or, in Xcode, you can select File » Swift Packages » Add Package Dependency... and specify the repository URL
`https://github.com/ggruen/CloudKitSyncStatus.git` and "up to next major version" `1.0.0`.
215 changes: 215 additions & 0 deletions Sources/CloudKitSyncStatus/SyncStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
//
// SyncStatus.swift
// Starfish
//
// Created by Grant Grueninger on 9/17/20.
// Copyright © 2020 Grant Grueninger. All rights reserved.
//

import Foundation
import CoreData
import Combine
import Network

/// The current status of iCloud sync as reported by `NSPersistentCloudKitContainer`
///
/// `SyncStatus` listens to the notifications sent out by `NSPersistentCloudKitContainer`
/// and translates them into a few published properties that can give your app a current state of its sync.
///
/// The primary use for this is to detect that rare condition in which CloudKit (and therefore your app) will just stop syncing with no warning and no notification
/// to the user. Well, now there's an immediate warning, and you can notify the user.
///
/// This SwiftUI view will display a red error image at the top of the screen if there's an import or export error:
///
/// import CloudKitSyncStatus
/// struct SyncStatusView: View {
/// @available(iOS 14.0, *)
/// @ObservedObject var syncStatus = SyncStatus.shared
///
/// var body: some View {
/// // Report only on real sync errors
/// if #available(iOS 14.0, *), (syncStatus.importError || syncStatus.exportError) {
/// VStack {
/// HStack {
/// if syncStatus.importError {
/// Image(systemName: "icloud.and.arrow.down").foregroundColor(.red)
/// }
/// if syncStatus.exportError {
/// Image(systemName: "icloud.and.arrow.up").foregroundColor(.red)
/// }
/// }
/// Spacer()
/// }
/// }
/// }
/// }
///
/// `SyncStatus` has a few "magic" properties, which are featured in the example above, and are what you
/// really should use. Avoid the temptation to offer a continuous "sync status", and _absolutely_ avoid the temptation to detect when "sync is finished",
/// as in a distributed environment (such as the one `NSPersistentCloudKitContainer` is part of), sync is never "finished", and you're asking for
/// "bad things", "unpredictable results", etc if you attempt to detect "sync is finished".
///
/// Anyway, the "magic" properties are:
/// - `syncError`, which tells you that something has gone wrong when nothing should be going wrong (i.e., there's an active network connection)
/// - `importError`, which tells you that the last import failed when it shouldn't have (i.e., there's an active network connection)
/// - `exportError`, which tells you that the last export failed when it shouldn't have (i.e., there's an active network connection)
///
/// Detecting these conditions is important because the usual "fix" for CloudKit not syncing is to delete the local database. This is fine if your import
/// stopped working, but if the export stopped working, it means that your user will lose any changes they made between the time the sync failed and
/// when the user noticed the failure. Previously, that time was based on when the user looked at two devices and noticed that they didn't contain the same data.
/// With `SyncStatus`, your app can report (or act on) that failure _immediately_, saving your user's data and your app's reputation.
@available(iOS 14.0, macCatalyst 14.0, macOS 11.0, *)
class SyncStatus: ObservableObject {
/// A singleton to use
static let shared = SyncStatus()

/// Status of NSPersistentCloudKitContainer setup.
///
/// This is `nil` if NSPersistentCloudKitContainer hasn't sent a notification about a event of type `setup`, `true` if the last notification
/// of an event of type `setup` succeeded, and `false` if the last notification of an event of type `setup` failed.
@Published var setupSuccessful: Bool? = nil

/// Status of last NSPersistentCloudKitContainer import.
///
/// This is `nil` if NSPersistentCloudKitContainer hasn't sent a notification about a event of type `import`, `true` if the last notification
/// of an event of type `import` succeeded, and `false` if the last notification of an event of type `import` failed.
/// On failure, the `lastImportError` property will contain the localized description of
@Published var importSuccessful: Bool? = nil

/// The localized description of the last import error, or `nil` if the last import succeeded (or no import has yet been run)
var lastImportError: String? = nil

/// Status of last NSPersistentCloudKitContainer export.
///
/// This is `nil` if NSPersistentCloudKitContainer hasn't sent a notification about a event of type `export`, `true` if the last notification
/// of an event of type `export` succeeded, and `false` if the last notification of an event of type `export` failed.
/// On failure, the `lastExportError` property will contain the localized description of
@Published var exportSuccessful: Bool? = nil

/// The localized description of the last import error, or `nil` if the last import succeeded (or no import has yet been run)
var lastExportError: String? = nil

/// Is the network available, as defined
@Published var networkAvailable: Bool? = nil

/// Is iCloud import sync broken?
///
/// Returns true if the network is available, NSPersistentCloudKitContainer ran an import, and the import reported an error
var importError: Bool {
return networkAvailable == true && importSuccessful == false
}

/// Is iCloud export sync broken?
///
/// Returns true if the network is available, NSPersistentCloudKitContainer ran an export, and the export reported an error
var exportError: Bool {
return networkAvailable == true && exportSuccessful == false
}

/// Is iCloud sync broken?
///
/// Returns true if the network is available and the last attempted sync (import or export) didn't succeed.
/// If this is true, your app likely needs to take some action to fix sync, e.g. clearing the local cache, quitting/restarting, etc.
/// See importError or exportError for the error.
var syncError: Bool {
return importError || exportError
}

/// Where we store Combine cancellables for publishers we're listening to, e.g. NSPersistentCloudKitContainer's notifications.
fileprivate var disposables = Set<AnyCancellable>()

/// Network path monitor that's used to track whether we can reach the network at all
// fileprivate let monitor: NetworkMonitor = NWPathMonitor()
fileprivate let monitor = NWPathMonitor()

/// The queue on which we'll run our network monitor
fileprivate let monitorQueue = DispatchQueue(label: "NetworkMonitor")

/// Creates a SyncStatus with values set manually and doesn't listen for NSPersistentCloudKitContainer notifications (for testing/previews)
init(setupSuccessful: Bool? = nil, importSuccessful: Bool? = nil, exportSuccessful: Bool? = nil,
networkAvailable: Bool? = nil) {
self.setupSuccessful = setupSuccessful
self.importSuccessful = importSuccessful
self.exportSuccessful = exportSuccessful
self.networkAvailable = networkAvailable
}

init() {
// XCode 12 is reporting that "eventChangedNotification" doesn't exist when compiling on Mac even with the
// @available set for the class. Temporary hack to let it compile on Mac.
// Fixed in Xcode 12.2 beta, but I'm leaving this commented out in case I need to add it back to do a release.
// #if !targetEnvironment(macCatalyst)
NotificationCenter.default.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink(receiveValue: { notification in
if let cloudEvent = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event {
// This translation to our "SyncEvent" lets us write unit tests, since
// NSPersistentCloudKitContainer.Event's properties are read-only (meaning we can't fire off a
// fake one).
let event = SyncEvent(from: cloudEvent)
self.setProperties(from: event)
}
})
.store(in: &disposables)
// #endif

// Update the network status when the OS reports a change. Note that we ignore whether the connection is
// expensive or not - we just care whether iCloud is _able_ to sync. If there's no network,
// NSPersistentCloudKitContainer will try to sync but report an error. We consider that a real error unless
// the network is not available at all. If it's available but expensive, it's still an error.
// Obstensively, if the user's device has iCloud syncing turned off (e.g. due to low power mode or not
// allowing syncing over cellular connections), NSPersistentCloudKitContainer won't try to sync.
// If that assumption is incorrect, we'll need to update the logic in this class.
monitor.pathUpdateHandler = { path in
DispatchQueue.main.async { self.networkAvailable = (path.status == .satisfied) }
}
monitor.start(queue: monitorQueue)
}

deinit {
// Clean up our listeners, just to be neat
monitor.cancel()
for cancellable in disposables {
cancellable.cancel()
}

}

/// Sets the status properties based on the information in the provided sync event
func setProperties(from event: SyncEvent) {
switch event.type {
case .import:
self.importSuccessful = event.succeeded
self.lastImportError = event.error?.localizedDescription
case .setup:
self.setupSuccessful = event.succeeded
case .export:
self.exportSuccessful = event.succeeded
self.lastExportError = event.error?.localizedDescription
@unknown default:
assertionFailure("New event type added to NSPersistenCloudKitContainer")
}
}

/// A sync event containing the values from NSPersistentCloudKitContainer.Event that we track
struct SyncEvent {
var type: NSPersistentCloudKitContainer.EventType
var succeeded: Bool
var error: Error?

/// Creates a SyncEvent from explicitly provided values (for testing)
init(type: NSPersistentCloudKitContainer.EventType, succeeded: Bool, error: Error) {
self.type = type
self.succeeded = succeeded
self.error = error
}

/// Creates a SyncEvent from an NSPersistentCloudKitContainer Event
init(from cloudKitEvent: NSPersistentCloudKitContainer.Event) {
self.type = cloudKitEvent.type
self.succeeded = cloudKitEvent.succeeded
self.error = cloudKitEvent.error
}
}
}

This file was deleted.

55 changes: 55 additions & 0 deletions Tests/CloudKitSyncStatusTests/SyncStatusTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import XCTest
import CoreData
@testable import CloudKitSyncStatus

@available(iOS 14.0, *)
final class SyncStatusTests: XCTestCase {
func testCanDetectImportError() {
// Given an active network connection
let syncStatus = SyncStatus(setupSuccessful: nil, importSuccessful: nil,
exportSuccessful: nil, networkAvailable: true)

// When NSPersistentCloudKitContainer reports an unsuccessful import
let errorText = "I don't like clouds"
let error = NSError(domain: errorText, code: 0, userInfo: nil)
let event = SyncStatus.SyncEvent(type: .import, succeeded: false,
error: error)
syncStatus.setProperties(from: event)

// Then importError is true
XCTAssertTrue(syncStatus.importError)

// and importSuccessful is false
XCTAssert(syncStatus.importSuccessful == false)

// and the error's localized description is "I don't like clouds"
XCTAssertEqual(syncStatus.lastImportError, error.localizedDescription)
}

func testCanDetectExportError() {
// Given an active network connection
let syncStatus = SyncStatus(setupSuccessful: nil, importSuccessful: nil,
exportSuccessful: nil, networkAvailable: true)

// When NSPersistentCloudKitContainer reports an unsuccessful import
let errorText = "I don't like clouds"
let error = NSError(domain: errorText, code: 0, userInfo: nil)
let event = SyncStatus.SyncEvent(type: .export, succeeded: false,
error: error)
syncStatus.setProperties(from: event)

// Then exportError is true
XCTAssertTrue(syncStatus.exportError)

// and exportSuccessful is false
XCTAssert(syncStatus.exportSuccessful == false)

// and the error's localized description is "I don't like clouds"
XCTAssertEqual(syncStatus.lastExportError, error.localizedDescription)
}

static var allTests = [
("testCanDetectImportError", testCanDetectImportError),
("testCanDetectExportError", testCanDetectExportError),
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(NSPersistentCloudKitContainerSyncStatusTests.allTests),
testCase(SyncStatusTests.allTests),
]
}
#endif
4 changes: 2 additions & 2 deletions Tests/LinuxMain.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import XCTest

import NSPersistentCloudKitContainerSyncStatusTests
import CloudKitSyncStatusTests

var tests = [XCTestCaseEntry]()
tests += NSPersistentCloudKitContainerSyncStatusTests.allTests()
tests += SyncStatusTests.allTests()
XCTMain(tests)
Loading

0 comments on commit c571e95

Please sign in to comment.