Search code examples
iosswiftapple-push-notificationsicloudcloudkit

Push Notification from CloudKit doesn't synchronize properly


I am trying to create a simple chat in IOS/Swift with iCloudKit. I'm modeling after this example: Create an App like Twitter: Push Notifications with CloudKit but changing it to become chat instead of Sweets.

The code's banner and badge work well to some degree and the pushing of data to CloudDashboard is fine and fast.

But the synchronization from the cloudKit to the devices doesn't work most of the time. Sometimes one device sees more than the other, sometimes less, just not too reliable. I am using the DEVELOPMENT environment in CloudKit.

What is the problem? Here is my code of implemented methods in appDelegate and the viewController:

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
    // Override point for customization after application launch.
    let notificationSettings = UIUserNotificationSettings(forTypes: [.Alert, .Badge, .Sound], categories: nil)
    UIApplication.sharedApplication().registerUserNotificationSettings(notificationSettings)
    UIApplication.sharedApplication().registerForRemoteNotifications()
    return true
}

func application(application: UIApplication, didReceiveRemoteNotification userInfo: [NSObject : AnyObject]) {
    let cloudKitNotification = CKNotification(fromRemoteNotificationDictionary: userInfo as! [String:NSObject])

    if cloudKitNotification.notificationType == CKNotificationType.Query {
        dispatch_async(dispatch_get_main_queue(), { () -> Void in
            NSNotificationCenter.defaultCenter().postNotificationName("performReload", object: nil)
        })
    }
}

func resetBadge () {
    let badgeReset = CKModifyBadgeOperation(badgeValue: 0)
    badgeReset.modifyBadgeCompletionBlock = { (error) -> Void in
        if error == nil {
            UIApplication.sharedApplication().applicationIconBadgeNumber = 0
        }
    }
    CKContainer.defaultContainer().addOperation(badgeReset)
}
func applicationWillResignActive(application: UIApplication) {

}

func applicationDidEnterBackground(application: UIApplication) {
    resetBadge()
}

func applicationWillEnterForeground(application: UIApplication) {
    dispatch_async(dispatch_get_main_queue(), { () -> Void in
        NSNotificationCenter.defaultCenter().postNotificationName("performReload", object: nil)
    })

}

func applicationDidBecomeActive(application: UIApplication) {
    resetBadge()
}

and this is the viewController

import UIKit
import CloudKit

class ChatViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate {

    @IBOutlet weak var dockViewHeightConstraint: NSLayoutConstraint!
    @IBOutlet weak var messageTextField: UITextField!
    @IBOutlet weak var sendButton: UIButton!
    @IBOutlet weak var messageTableView: UITableView!

    var chatMessagesArray = [CKRecord]()
    var messagesArray: [String] = [String]()

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        self.messageTableView.delegate = self
        self.messageTableView.dataSource = self
        // set self as the delegate for the textfield
        self.messageTextField.delegate = self

        // add a tap gesture recognizer to the tableview
        let tapGesture:UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ChatViewController.tableViewTapped))
        self.messageTableView.addGestureRecognizer(tapGesture)

        setupCloudKitSubscription()

        dispatch_async(dispatch_get_main_queue(), { () -> Void in
            NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(ChatViewController.retrieveMessages), name: "performReload", object: nil)
        })

        // retrieve messages form iCloud
        self.retrieveMessages()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    @IBAction func sendButtonTapped(sender: UIButton) {

        // Call the end editing method for the text field
        self.messageTextField.endEditing(true)

        // Disable the send button and textfield
        self.messageTextField.enabled = false
        self.sendButton.enabled = false

        // create a cloud object
        //var newMessageObject
        // set the text key to the text of the messageTextField

        // save the object
        if messageTextField.text != "" {
            let newChat = CKRecord(recordType: "Chat")
            newChat["content"] = messageTextField.text
            newChat["user1"] = "john"
            newChat["user2"] = "mark"

            let publicData = CKContainer.defaultContainer().publicCloudDatabase
            //TODO investigate if we want to do public or private

            publicData.saveRecord(newChat, completionHandler: { (record:CKRecord?, error:NSError?) in
                if error == nil {
                    dispatch_async(dispatch_get_main_queue(), {() -> Void in
                        print("chat saved")
                        self.retrieveMessages()
                    })
                }
            })
        }

        dispatch_async(dispatch_get_main_queue()) {
            // Enable the send button and textfield
            self.messageTextField.enabled = true
            self.sendButton.enabled = true
            self.messageTextField.text = ""
        }
    }

    func retrieveMessages() {
        print("inside retrieve messages")
        // create a new cloud query
        let publicData = CKContainer.defaultContainer().publicCloudDatabase

        // TODO: we should use this
        let predicate = NSPredicate(format: "user1 in %@ AND user2 in %@", ["john", "mark"], ["john", "mark"])
        let query = CKQuery(recordType: "Chat", predicate: predicate)

        //let query = CKQuery(recordType: "Chat", predicate: NSPredicate(format: "TRUEPREDICATE", argumentArray: nil))

        query.sortDescriptors = [NSSortDescriptor(key:"creationDate", ascending: true)]
        publicData.performQuery(query, inZoneWithID: nil) { (results: [CKRecord]?, error:NSError?) in
            if let chats = results {
                dispatch_async(dispatch_get_main_queue(), {() -> Void in
                    self.chatMessagesArray = chats
                    print("count is: \(self.chatMessagesArray.count)")
                    self.messageTableView.reloadData()
                })
            }
        }
    }

    func tableViewTapped () {
        // Force the textfied to end editing
        self.messageTextField.endEditing(true)
    }

    // MARK: TextField Delegate Methods
    func textFieldDidBeginEditing(textField: UITextField) {
        // perform an animation to grow the dockview
        self.view.layoutIfNeeded()
        UIView.animateWithDuration(0.5, animations: {
            self.dockViewHeightConstraint.constant = 350
            self.view.layoutIfNeeded()
            }, completion: nil)
    }

    func textFieldDidEndEditing(textField: UITextField) {

        // perform an animation to grow the dockview
        self.view.layoutIfNeeded()
        UIView.animateWithDuration(0.5, animations: {
            self.dockViewHeightConstraint.constant = 60
            self.view.layoutIfNeeded()
            }, completion: nil)
    }

    // MARK: TableView Delegate Methods

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

        // Create a table cell
        let cell = self.messageTableView.dequeueReusableCellWithIdentifier("MessageCell")! as UITableViewCell

        // customize the cell
        let chat = self.chatMessagesArray[indexPath.row]
        if let chatContent = chat["content"] as? String {
            let dateFormat = NSDateFormatter()
            dateFormat.dateFormat = "MM/dd/yyyy"
            let dateString = dateFormat.stringFromDate(chat.creationDate!)
            cell.textLabel?.text = chatContent
            //cell.detailTextLabel?.text = dateString
        }
        //cell.textLabel?.text = self.messagesArray[indexPath.row]

        // return the cell
        return cell
    }

    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        //print(tableView.frame.size)
        //print("count: \(self.chatMessagesArray.count)")
        return self.chatMessagesArray.count
    }
    /*
    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        // Get the new view controller using segue.destinationViewController.
        // Pass the selected object to the new view controller.
    }
    */

    // MARK: Push Notifications

    func setupCloudKitSubscription() {
        let userDefaults = NSUserDefaults.standardUserDefaults()
        print("the value of the bool is: ")
        print(userDefaults.boolForKey("subscribed"))
        print("print is above")
        if userDefaults.boolForKey("subscribed") == false { // TODO: maybe here we do multiple types of subscriptions

            let predicate = NSPredicate(format: "user1 in %@ AND user2 in %@", ["john", "mark"], ["john", "mark"])
            //let predicate = NSPredicate(format: "TRUEPREDICATE", argumentArray: nil)
            let subscription = CKSubscription(recordType: "Chat", predicate: predicate, options: CKSubscriptionOptions.FiresOnRecordCreation)
            let notificationInfo = CKNotificationInfo()
            notificationInfo.alertLocalizationKey = "New Chat"
            notificationInfo.shouldBadge = true

            subscription.notificationInfo = notificationInfo

            let publicData = CKContainer.defaultContainer().publicCloudDatabase
            publicData.saveSubscription(subscription) { (subscription: CKSubscription?, error: NSError?) in
                if error != nil {
                    print(error?.localizedDescription)
                } else {
                    userDefaults.setBool(true, forKey: "subscribed")
                    userDefaults.synchronize()
                }
            }
        }

    }
}

Solution

  • I see that you are using the push notification as a signal to reload all data. CloudKit does use a cashing mechanism (details of that are unknown) for a specific predicate. In your case you are executing the same predicate over and over. Because of this cashing you could miss records. Try doing a manual refresh after a minute or so and you will see that then suddenly your records will appear.

    You should handle push notifications differently. When you receive a notification you should also query the notification messages (You could get 1 push notification while there are multiple notifications. This can happen when you have a lot of notifications)

    But first you should handle the current notification. Start with a check if the notification is for a query using:

    if cloudKitNotification.notificationType == CKNotificationType.Query {
    

    Then cast it to a query notification using:

    if let queryNotification = cloudNotification as? CKQueryNotification
    

    Get the recordID

    if let recordID = queryNotification.recordID {
    

    Then depending on what has happened change your local (in app) data. You can check that using:

    if queryNotification.queryNotificationReason == .RecordCreated
    

    Of course it could also be . RecordDeleted or .RecordUpdated

    If it's a .RecordCreated or .RecordUpdated you should fetch that record using the recordID

    Then when that is processed, you have to fetch other not processed notifications. For that you have to create a CKFetchNotificationChangesOperation You do have to be aware that you have to pass it a change token. If you send it a nil you will get all notifications that were ever created for your subscriptions. When the operations finishes it will send you a new change token. You should save that into your userDefaults so that you can use that the next time when you start processing notifications.

    The code for that query will look something like:

    let operation = CKFetchNotificationChangesOperation(previousServerChangeToken: self.previousChangeToken)
    operation.notificationChangedBlock = { notification in
    ...
    operation.fetchNotificationChangesCompletionBlock = { changetoken, error in
    ...
    operation.start()
    

    Then for that notification you should execute the same logic as above for the initial notification. And the changetoken should be saved.

    One other benefit of this mechanism is that your records come in one by one and you could create a nice animation that updates your tableview.