Skip to content

Instantly share code, notes, and snippets.

@roymckenzie
Last active August 10, 2022 12:01
Show Gist options
  • Save roymckenzie/f0c70e48b603a495c458bdd1cfc8ec02 to your computer and use it in GitHub Desktop.
Save roymckenzie/f0c70e48b603a495c458bdd1cfc8ec02 to your computer and use it in GitHub Desktop.
Basic Subscription and Fetch manager and setup for CloudKit
import UIKit
import CloudKit
import RealmSwift
/// Handles common methods for subscriptions across multiple databases
enum CloudKitDatabaseSubscription: String {
case `private`
case `public`
}
extension CloudKitDatabaseSubscription {
var database: CKDatabase {
switch self {
case .private:
return CKContainer.default().privateCloudDatabase
case .public:
return CKContainer.default().publicCloudDatabase
}
}
var subscription: CKSubscription {
return CKDatabaseSubscription(subscriptionID: subscriptionID)
}
var subscriptionID: String {
return "\(rawValue)SubscriptionIDKey"
}
var changeToken: CKServerChangeToken? {
return UserDefaults.standard.object(forKey: changeTokenKey) as? CKServerChangeToken
}
var saved: Bool {
return UserDefaults.standard.bool(forKey: savedSubscriptionKey)
}
func set(_ changeToken: CKServerChangeToken?) {
UserDefaults.standard.set(changeToken, forKey: changeTokenKey)
}
func saved(_ saved: Bool) {
UserDefaults.standard.set(saved, forKey: savedSubscriptionKey)
}
private var changeTokenKey: String {
return "\(rawValue)DatabaseChangeTokenKey"
}
private var savedSubscriptionKey: String {
return "\(rawValue)SavedSubscriptionKey"
}
}
// In your app delegate add the following code to subscribe to database
// changes and to fetch changes when recieving a notification.
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
// Creates a private database subscription to listen for changes
if !CloudKitDatabaseSubscription.private.saved {
CloudKitSyncManager.create(databaseSubscription: .private)
}
// Register for notifications
registerForNotifications(application: application)
return true
}
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void){
let cloudKitNotification = CKNotification(fromRemoteNotificationDictionary: userInfo)
guard let subscriptionID = cloudKitNotification.subscriptionID else {
print("Received a remote notification for unknown subscriptionID")
return
}
switch subscriptionID {
case CloudKitDatabaseSubscription.private.rawValue:
CloudKitSyncManager.fetchChanges(for: .private)
case CloudKitDatabaseSubscription.public.rawValue:
CloudKitSyncManager.fetchChanges(for: .public)
default: break
// WHATEVER I DON'T EVEN KNOW WHO YOU ARE
}
}
}
extension AppDelegate {
func registerForNotifications(application: UIApplication) {
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { success, error in
if let error = error {
print("Error registering for notifications: \(error)")
}
if success {
print("Successfully registered for notifications")
application.registerForRemoteNotifications()
}
}
} else {
let settings = UIUserNotificationSettings(types: [.alert], categories: nil)
application.registerUserNotificationSettings(settings)
application.registerForRemoteNotifications()
}
}
}
/// Provides a place to store and retrieve `CKServerChangeToken` objects related to a `CKRecordZone`
struct RecordZoneChangeTokenProvider {
private static var changeTokenKey: (CKRecordZoneID) -> String = { recordZoneID in
return "\(recordZoneID.zoneName)ChangeTokenKey"
}
static func getChangeToken(for recordZoneID: CKRecordZoneID) -> CKServerChangeToken? {
return UserDefaults.standard.object(forKey: changeTokenKey(recordZoneID)) as? CKServerChangeToken
}
static func set(_ changeToken: CKServerChangeToken?, for recordZoneID: CKRecordZoneID) {
UserDefaults.standard.set(changeToken, forKey: changeTokenKey(recordZoneID))
}
}
// CloudKitSyncManager provides VERY BASIC methods to get you started pulling information
// after receiving a remote notification.
struct CloudKitSyncManager {
// Create a database subscription to receive notifications for changes
static func create(databaseSubscription: CloudKitDatabaseSubscription) {
// Don't save if it's already been saved
// Server will throw error
if databaseSubscription.saved { return }
let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [databaseSubscription.subscription],
subscriptionIDsToDelete: nil)
operation.modifySubscriptionsCompletionBlock = { subscriptions, _, error in
if let error = error {
print("Error saving subscription: \(error.localizedDescription)")
return
}
databaseSubscription.saved(true)
}
databaseSubscription
.database
.add(operation)
}
// Call in AppDelegate when
// application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
static func fetchChanges(for subscription: CloudKitDatabaseSubscription) {
// Create operation to fetch database changes
// Include previous changeToken if available
let operation = CKFetchDatabaseChangesOperation(previousServerChangeToken: subscription.changeToken)
// Record Zone IDs to fetch updates for
var recordZoneIDs = [CKRecordZoneID]()
// Block called when a Record Zone is returned that has changes
operation.recordZoneWithIDChangedBlock = { recordZoneID in
recordZoneIDs.append(recordZoneID)
}
operation.fetchDatabaseChangesCompletionBlock = { changeToken, _, error in
subscription.set(changeToken)
}
subscription.database.add(operation)
}
// Fetch record changes for multiple recordZoneIDs
static func fetchChanges(for recordZoneIDs: [CKRecordZoneID]) {
if recordZoneIDs.isEmpty { return }
var recordsToSave = [CKRecord]()
var recordIDsToDelete = [CKRecordID]()
var recordZoneIDsToTryAgain = [CKRecordZoneID]()
// Create options for each record zone so that you can use a changeToken
var optionsByRecordZone = [CKRecordZoneID : CKFetchRecordZoneChangesOptions]()
for recordZoneID in recordZoneIDs {
let options = CKFetchRecordZoneChangesOptions()
options.previousServerChangeToken = RecordZoneChangeTokenProvider.getChangeToken(for: recordZoneID)
return optionsByRecordZone[recordZoneID] = options
}
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: recordZoneIDs,
optionsByRecordZoneID: optionsByRecordZone)
// Add changed record to records array for processing at completion
operation.recordChangedBlock = { record in
recordsToSave.append(record)
}
// Add deleted recrod ID to array for processing at completion
operation.recordWithIDWasDeletedBlock = { recordID, _ in
recordIDsToDelete.append(recordID)
}
// Save record zone changes
operation.recordZoneChangeTokensUpdatedBlock = { recordZoneID, changeToken, _ in
RecordZoneChangeTokenProvider.set(changeToken, for: recordZoneID)
}
// Called when an individual recordZone has completed
operation.recordZoneFetchCompletionBlock = { recordZoneID, changeToken, _, moreComing, error in
if let error = error {
print("Error fetching changes for record zone: \(error.localizedDescription)")
}
if moreComing {
RecordZoneChangeTokenProvider.set(changeToken, for: recordZoneID)
recordZoneIDsToTryAgain.append(recordZoneID)
}
}
// Called when all the changes have been fetched
operation.fetchRecordZoneChangesCompletionBlock = { error in
if let error = error {
print("Error fetching Record Zone Changes: \(error.localizedDescription)")
}
// Let's still process changes we did receive even if there was an error
updateRealm(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
fetchChanges(for: recordZoneIDsToTryAgain)
}
}
/// Update Realm with local
static func updateRealm(recordsToSave: [CKRecord]?, recordIDsToDelete: [CKRecordID]?) {
// Decode CKRecords to your local Realm objects
// Delete Realm objects with matching recordID names
// Saves here will trigger `addNotificationBlock` blocks in places
// where you are subscribed to Realm notifications
do {
let realm = try Realm()
try realm.write {
// realm.save(objects)
}
} catch {
print("Error setting up Realm \(error.localizedDescription)")
}
}
}
import UIKit
import RealmSwift
final class DataDisplayViewController: UIViewController {
private var realmNotificationToken: NotificationToken?
override func viewDidLoad() {
super.viewDidLoad()
startRealmNotification()
}
private func startRealmNotification() {
do {
let realm = try Realm()
realmNotificationToken = realm.addNotificationBlock() { [weak self] _, _ in
// TODO:- UPDATE UI
}
} catch {
print("Error setting up Realm Notification: \(error.localizedDescription)")
}
}
deinit {
realmNotificationToken?.stop()
}
}
@doodzik
Copy link

doodzik commented Oct 28, 2017

This is great! Thank you for sharing.
You could make a pod out of it.

@doodzik
Copy link

doodzik commented Nov 7, 2017

You need to add the notificationInfo to a subscription otherwise it will throw an error when saving it to iCloud

    var subscription: CKSubscription {
        let sub = CKDatabaseSubscription(subscriptionID: subscriptionID)

        let notificationInfo = CKNotificationInfo()
        notificationInfo.shouldSendContentAvailable = true
        sub.notificationInfo = notificationInfo

        return sub
    }

In the switch statement in line 85 shouldn't access the rawValue. It should look something like this:

switch subscriptionID {
        case CloudKitDatabaseSubscription.private.subscriptionID:
            CloudKitSyncManager.fetchChanges(for: .private)
        case CloudKitDatabaseSubscription.public.subscriptionID:
            CloudKitSyncManager.fetchChanges(for: .public)
        default: break
            // WHATEVER I DON'T EVEN KNOW WHO YOU ARE
        }

@VolkovsaVSA
Copy link

VolkovsaVSA commented Dec 13, 2020

"You need to add the notificationInfo to a subscription otherwise it will throw an error when saving it to iCloud"

Tell me where it should be added specifically, am I confused?
When user "B" writes changes to the sharedDB, then changes to existing records are made, but adding new and deleting old records does not occur. Gives the error "Couldn't fetch some items when fetching changes"

PS
I have my own code, but the error is similar to the one you pointed out about adding notificationInfo to a subscription.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment