I have run into an issue when saving a CKShare. When executing the share everything works as it should, but if the share is cancelled and tried to share again I receive the error:
"Server Record Changed" (14/2004); server message = "client oplock error updating record"
The following is my sharing code:
func share(_ deck: Deck) {
let zoneID = CKManager.defaultManager.sharedZone?.zoneID
if let deckName = deck.name {
selectedDeckForPrivateShare = deckName
}
var records = [CKRecord]()
let deckRecord = deck.deckToCKRecord(zoneID)
records.append(deckRecord)
let reference = CKReference(record: deckRecord, action: .none)
for index in deck.cards {
let cardRecord = index.cardToCKRecord(zoneID)
cardRecord["deck"] = reference
cardRecord.parent = reference
records.append(cardRecord)
}
let share = CKShare(rootRecord: deckRecord)
share[CKShareTitleKey] = "\(String(describing: deck.name)))" as CKRecordValue?
records.append(share)
let sharingController = UICloudSharingController { (controller, preparationCompletion) in
let modifyOP = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil)
modifyOP.qualityOfService = .userInteractive
modifyOP.modifyRecordsCompletionBlock = { (savedRecords,deletedRecordIDs,error) in
preparationCompletion(share, CKContainer.default(), error)
controller.delegate = self
if let error = error {
print(error)
}
}
self.privateDatabase.add(modifyOP)
}
sharingController.delegate = self
self.present(sharingController, animated: true, completion: {
})
}
The two methods deckToCKRecord() and cardToCKRecord() are what I use to convert from Core Data to CKRecord so that they can be shared. Thank you.
It's possible that even though the sharing operation is cancelled (since you weren't explicit on what you mean by cancelled), the CKShare record you set up is actually created and saved in your private database. You simply cancelled changing the participation status (e.g. invited) of the other user. Therefore when you go to repeat this procedure, you are trying to re-save the same CKShare, albeit set up again thus likely modifying the change tag. CloudKit sees this and returns a "Server Record Changed" error.
If I'm correct, you could handle this one of two ways. First, set the value of the CKModifyRecordsOperation variable savePolicy to .allKeys, for example, right where you are setting the qualityOfService var. This will mandate that even if CloudKit finds the record on your server, it will automatically overwrite it. If you are sure that the uploaded version should ALWAYS win, this is a good approach (and only results in a single network call).
A second more general approach that most people use is to first make a CKFetchRecordsOperation with the root record's share var recordID. All CKRecord objects have a share var which is a CKReference, but this will be nil if the object hasn't been shared already. If I am right, on your second try after cancelling, you will find the root record's share reference to contain the CKShare you set up on the first try. So grab that share, then with it, initiate the rest of your parent and other references as you did before!
Related
This is how I define fetching changes:
func fetchAllChanges(isFetchedFirstTime: Bool) {
let zone = CKRecordZone(zoneName: "fieldservice")
let options = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
options.previousServerChangeToken = Token.privateZoneServerChangeToken //initially it is nil
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zone.zoneID], configurationsByRecordZoneID: [zone.zoneID: options])
operation.fetchAllChanges = isFetchedFirstTime
operation.database = CloudAssistant.shared.privateDatabase
// another stuff
}
When I fetch all of them first time, then fetchAllChanges is false. So I only get server change token and save it for another use. No changes for records is returned. And it is ok;)
The problem is when I try to fetch it SECOND TIME. Since then nothing changed, server change token is not nil now, but fetchAllChanges is true because I need all the changes since first fetch (last server change token). It should work like this in my opinion.
But the SECOND TIME I got ALL THE CHANGES from my cloudkit (a few thousands of records and alll the changes). Why? I thought I told cloudkit that I do not want it like this. What am I doing wrong?
I have implemented #vadian answer, but my allChanges is always empty. Why?
func fetchPrivateLatestChanges(handler: ProgressHandler?) async throws -> ([CKRecord], [CKRecord.ID]) {
/// `recordZoneChanges` can return multiple consecutive changesets before completing, so
/// we use a loop to process multiple results if needed, indicated by the `moreComing` flag.
var awaitingChanges = true
var changedRecords = [CKRecord]()
var deletedRecordIDs = [CKRecord.ID]()
let zone = CKRecordZone(zoneName: "fieldservice")
while awaitingChanges {
/// Fetch changeset for the last known change token.
print("🏆TOKEN: - \(lastChangeToken)")
let allChanges = try await privateDatabase.recordZoneChanges(inZoneWith: zone.zoneID, since: lastChangeToken)
/// Convert changes to `CKRecord` objects and deleted IDs.
let changes = allChanges.modificationResultsByID.compactMapValues { try? $0.get().record }
print(changes.count)
changes.forEach { _, record in
print(record.recordType)
changedRecords.append(record)
handler?("Fetching \(changedRecords.count) private records.")
}
let deletetions = allChanges.deletions.map { $0.recordID }
deletedRecordIDs.append(contentsOf: deletetions)
/// Save our new change token representing this point in time.
lastChangeToken = allChanges.changeToken
/// If there are more changes coming, we need to repeat this process with the new token.
/// This is indicated by the returned changeset `moreComing` flag.
awaitingChanges = allChanges.moreComing
}
return (changedRecords, deletedRecordIDs)
}
And here is what is repeated on console:
🏆TOKEN: - nil
0
🏆TOKEN: - Optional(<CKServerChangeToken: 0x1752a630; data=AQAAAAAAAACXf/////////+L6xlFzHtNX6UXeP5kslOE>)
0
🏆TOKEN: - Optional(<CKServerChangeToken: 0x176432f0; data=AQAAAAAAAAEtf/////////+L6xlFzHtNX6UXeP5kslOE>)
0
🏆TOKEN: - Optional(<CKServerChangeToken: 0x176dccc0; data=AQAAAAAAAAHDf/////////+L6xlFzHtNX6UXeP5kslOE>)
0
... ...
This is how I use it:
TabView {
//my tabs
}
.tabViewStyle(PageTabViewStyle())
.task {
await loadData()
}
private func loadData() async {
await fetchAllInitialDataIfNeeded { error in
print("FINITO>>🏆")
print(error)
}
}
private func fetchAllInitialDataIfNeeded(completion: #escaping ErrorHandler) async {
isLoading = true
do {
let sthToDo = try await assistant.fetchPrivateLatestChanges { info in
self.loadingText = info
}
print(sthToDo)
} catch let error as NSError {
print(error.localizedDescription)
}
Assuming you have implemented also the callbacks of CKFetchRecordZoneChangesOperation you must save the token received by the callbacks permanently for example in UserDefaults.
A smart way to do that is a computed property
var lastChangeToken: CKServerChangeToken? {
get {
guard let tokenData = UserDefaults.standard.data(forKey: Key.zoneChangeToken) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
}
set {
if let token = newValue {
let tokenData = try! NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
UserDefaults.standard.set(tokenData, forKey: Key.zoneChangeToken)
} else {
UserDefaults.standard.removeObject(forKey: Key.zoneChangeToken)
}
}
}
The struct Key is for constants, you can add more keys like the private subscription ID etc.
struct Key {
let zoneChangeToken = "zoneChangeToken"
}
Secondly I highly recommend to use the async/await API to fetch the latest changes because it get's rid of the complicated and tedious callbacks.
As you have a singleton CloudAssistant implement the method there and use a property constant for the zone. In init initialize the privateDatabase and also the zone properties.
This is the async/await version of fetchLatestChanges, it returns the new records and also the deleted record IDs
/// Using the last known change token, retrieve changes on the zone since the last time we pulled from iCloud.
func fetchLatestChanges() async throws -> ([CKRecord], [CKRecord.ID]) {
/// `recordZoneChanges` can return multiple consecutive changesets before completing, so
/// we use a loop to process multiple results if needed, indicated by the `moreComing` flag.
var awaitingChanges = true
var changedRecords = [CKRecord]()
var deletedRecordIDs = [CKRecord.ID]()
while awaitingChanges {
/// Fetch changeset for the last known change token.
let allChanges = try await privateDatabase.recordZoneChanges(inZoneWith: zone, since: lastChangeToken)
/// Convert changes to `CKRecord` objects and deleted IDs.
let changes = allChanges.modificationResultsByID.compactMapValues { try? $0.get().record }
changes.forEach { _, record in
changedRecords.append(record)
}
let deletetions = allChanges.deletions.map { $0.recordID }
deletedRecordIDs.append(contentsOf: deletetions)
/// Save our new change token representing this point in time.
lastChangeToken = allChanges.changeToken
/// If there are more changes coming, we need to repeat this process with the new token.
/// This is indicated by the returned changeset `moreComing` flag.
awaitingChanges = allChanges.moreComing
}
return (changedRecords, deletedRecordIDs)
}
I believe you misunderstand how this works. The whole point of passing a token to the CKFetchRecordZoneChangesOperation is so that you only get the changes that have occurred since that token was set. If you pass nil then you get changes starting from the beginning of the lifetime of the record zone.
The fetchAllChanges property is very different from the token. This property specifies whether you need to keep calling a new CKFetchRecordZoneChangesOperation to get all of the changes since the given token or whether the framework does it for you.
On a fresh install of the app you would want to pass nil for the token. Leave the fetchAllChanges set to its default of true. When the operation runs you will get every change ever made to the record zone. Use the various completion blocks to handle those changes. In the end you will get an updated token that you need to save.
The second time you run the operation you use the last token you obtained from the previous run of the operation. You still leave fetchAllChanges set to true. You will now get only the changes that may have occurred since the last time you ran the operation.
The documentation for CKFetchRecordZoneChangesOperation shows example code covering all of this.
I am currently working on a project with a multi user system. The user is able to create new profiles which are saved persistently using CoreData.
My problem is: Only one profile can be the active one at a single time, so I would like to get the ObjectID of the created profile and save it to UserDefaults.
Further I was thinking that as soon as I need the data of the active profile, I can simply get the ObjectID from UserDefaults and execute a READ - Request which only gives me back the result with that specific ObjectID.
My code so far for SAVING THE DATA:
// 1. Create new profile entry to the context.
let newProfile = Profiles(context: context)
newProfile.idProfileImage = idProfileImage
newProfile.timeCreated = Date()
newProfile.gender = gender
newProfile.name = name
newProfile.age = age
newProfile.weight = weight
// 2. Save the Object ID to User Defaults for "activeUser".
// ???????????????????
// ???????????????????
// 3. Try to save the new profile by saving the context to the persistent container.
do {
try context.save()
} catch {
print("Error saving context \(error)")
}
My code so far for READING THE DATA
// 1. Creates an request that is just pulling all the data.
let request: NSFetchRequest<Profiles> = Profiles.fetchRequest()
// 2. Try to fetch the request, can throw an error.
do {
let result = try context.fetch(request)
} catch {
print("Error reading data \(error)")
}
As you can see, I haven't been able to implement Part 2 of the first code block. The new profile gets saved but the ObjectID isn't saved to UserDefaults.
Also Party 1 of the second code block is not the final goal. The request just gives you back all the data of that entity, not only the one with the ObjectID I stored in User Defaults.
I hope you guys have an idea on how to solve this problem.
Thanks for your help in advance guys!
Since NSManagedObjectID does not conform to one of the types handled by UserDefaults, you'll have to use another way to represent the object id. Luckily, NSManagedObjectID has a uriRepresentation() that returns a URL, which can be stored in UserDefaults.
Assuming you are using a NSPersistentContainer, here's an extension that will handle the storage and retrieval of a active user Profile:
extension NSPersistentContainer {
private var managedObjectIDKey: String {
return "ActiveUserObjectID"
}
var activeUser: Profile? {
get {
guard let url = UserDefaults.standard.url(forKey: managedObjectIDKey) else {
return nil
}
guard let managedObjectID = persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else {
return nil
}
return viewContext.object(with: managedObjectID) as? Profile
}
set {
guard let newValue = newValue else {
UserDefaults.standard.removeObject(forKey: managedObjectIDKey)
return
}
UserDefaults.standard.set(newValue.objectID.uriRepresentation(), forKey: managedObjectIDKey)
}
}
}
This uses a method on NSPersistentStoreCoordinator to construct a NSManagedObjectID from a URI representation.
I'm dealing with the scenario, where a user has previously deleted the app and has now re-installed it.
It was hitting my delta fetch function, which is receiving a lot of old subscription notifications, mostly deletes. But not downloading current records.
I'm now adding code to perform a fetch on each record type to download all the data.
I'd like to reset delta fetch server token, so the app doesn't have to process old subscriptions notifications. However I can't find how to do this, maybe it's not possible.
Are you referring to CKServerChangeToken (documentation) when you say "delta fetch server token"? And are you attempting to sync within the CloudKit private database?
Assuming that is true, here is an example of how I fetch changes from the private database and keep track of the sync token:
//MARK: Fetch Latest from CloudKit from private DB
func fetchPrivateCloudKitChanges(){
print("Fetching private changes...")
//:::
let privateZoneId = CKRecordZone.ID(zoneName: CloudKit.zoneName, ownerName: CKCurrentUserDefaultName)
/----
let options = CKFetchRecordZoneChangesOperation.ZoneOptions()
options.previousServerChangeToken = previousChangeToken
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [privateZoneId], optionsByRecordZoneID: [recordZoneID:options])
//Queue up the updated records to process below
var records = [CKRecord]()
operation.recordChangedBlock = { record in
records.append(record)
}
operation.recordWithIDWasDeletedBlock = { recordId, type in
//Process a deleted record in your local database...
}
operation.recordZoneChangeTokensUpdatedBlock = { (zoneId, token, data) in
// Save new zone change token to disk
previousChangeToken = token
}
operation.recordZoneFetchCompletionBlock = { zoneId, token, _, _, error in
if let error = error {
print(error)
}
// Write this new zone change token to disk
previousChangeToken = token
}
operation.fetchRecordZoneChangesCompletionBlock = { error in
if let error = error {
print(error
}else{
//Success! Process all downloaded records from `records` array above...
//records...
}
}
CloudKit.privateDB.add(operation)
}
//Change token property that gets saved and retrieved from UserDefaults
var previousChangeToken: CKServerChangeToken? {
get {
guard let tokenData = defaults.object(forKey: "previousChangeToken") as? Data else { return nil }
return NSKeyedUnarchiver.unarchiveObject(with: tokenData) as? CKServerChangeToken
}
set {
guard let newValue = newValue else {
defaults.removeObject(forKey: "previousChangeToken")
return
}
let data = NSKeyedArchiver.archivedData(withRootObject: newValue)
defaults.set(data, forKey: "previousChangeToken")
}
}
Your specific situation might differ a little, but I think this is how it's generally supposed to work when it comes to staying in sync with CloudKit.
Update
You could try storing the previousServerChangeToken on the Users record in CloudKit (you would have to add it as a new field). Each time the previousServerChangeToken changes in recordZoneFetchCompletionBlock you would have to save it back to iCloud on the user's record.
I have two iCloud accounts (A and B) on two different devices. From one of them (A) I share ckrecord to another one (B) like this:
let controller = UICloudSharingController { controller, preparationCompletionHandler in
let share = CKShare(rootRecord: record)
share[CKShareTitleKey] = "title" as CKRecordValue
share[CKShareTypeKey] = "pl.blueworld.fieldservice" as CKRecordValue
share.publicPermission = .readWrite
let modifyOperation = CKModifyRecordsOperation(recordsToSave: [record, share], recordIDsToDelete: nil)
modifyOperation.savePolicy = .ifServerRecordUnchanged
modifyOperation.perRecordCompletionBlock = { record, error in
print(error?.localizedDescription ?? "")
}
modifyOperation.modifyRecordsCompletionBlock = { records, recordIds, error in
print(share.url)
preparationCompletionHandler(share, CloudAssistant.shared.container, error)
}
CloudAssistant.shared.container.privateCloudDatabase.add(modifyOperation)
}
controller.delegate = self
UIViewController.top()?.present(controller, animated: true)
When second device (B) did accept cloudkit share I fetch record and subscribe for changes:
func application(_ application: UIApplication, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShareMetadata) {
let acceptSharesOperation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata])
acceptSharesOperation.perShareCompletionBlock = { metadata, share, error in
if let error = error {
UIAlertController.show(withMessage: error.localizedDescription)
} else {
let operation = CKFetchRecordsOperation(recordIDs: [cloudKitShareMetadata.rootRecordID])
operation.perRecordCompletionBlock = { record, _, error in
if let error = error {
UIAlertController.show(withMessage: error.localizedDescription)
} else if let record = record {
CloudAssistant.shared.save(records: [record], recordIDsToDelete: [])
let options: CKQuerySubscriptionOptions = [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
let territorySubscription = CKQuerySubscription(recordType: "Territory", predicate: NSPredicate(value: true), options: options)
let notificationInfo = CKNotificationInfo()
notificationInfo.shouldBadge = false
notificationInfo.shouldSendContentAvailable = true
territorySubscription.notificationInfo = notificationInfo
CloudAssistant.shared.sharedDatabase?.save(territorySubscription) { _, _ in }
}
}
CloudAssistant.shared.container.sharedCloudDatabase.add(operation)
}
}
acceptSharesOperation.qualityOfService = .userInteractive
CKContainer(identifier: cloudKitShareMetadata.containerIdentifier).add(acceptSharesOperation)
}
Now from device A I successfully (I am sure about that, changes is saved in iCloud) perform an update on a record shared with others. But device B doesn't know about that, unless I fetch record manually once again.
But from the other side, it works pretty well.
If I successfully perform an update on a record shared with me (on device B) then device A magically gets a notification about change and everything is fine. What makes the difference?
How to subscribe for changes on a records shared with me?
iOS 11, Swift 4, Xcode 9.
Here's my checklist for debugging subscription notifications not appearing as expected. Sounds like you may have ruled some of these out already.
Make sure the app is registered for notifications
Make sure notifications are enabled on the device for this app
Make sure all devices are using the same container
On the next app startup, read all subscriptions using fetchAllSubscriptionsWithCompletionHandler and NSLog each sub's details (especially: subscriptionID, trigger options, record type and the predicate). Verify that the expected subs exist. Verify that each sub's predicate matches expectations (in this case, compare the predicates you find on both devices).
I wasted a bunch of time debugging "missing" notifications when:
My locally-built version was using the TEST environment and the TestFlight users were accessing the PROD environment. Debugging step 2 found this.
When inadvertently re-using a subscription ID, thus each new sub overwrote the prior one. Debugging step 4 eventually revealed the problem
So far, these four debugging steps have helped me understand all of my "missing notification" problems. Once I understood why the notifs didn't appear, that narrowed down which block of code was responsible.
Let me describe the basic flow that I am trying to implement:
User logs in
System retrieves list of user's connections using HTTP request to 3rd party API (could be in the 1000s). I'll call this list userConnections
System retrieves stored connections from my app's database (could be in the 100,000s). I'll call this list connections
System then checks to see if each userConnection exists in the connections list already and if not, saves it to database:
for userConnection in userConnections {
if connections.contains(userConnection) {
//do nothing
} else {
saveRecord(userConnection)
}
}
The problem with this is that when the first users log in, the app will try to make 1000 saveRecord calls in a second which the CloudKit server will not allow.
How can I implement this in a different way using CloudKit and Swift so that I keep it to an acceptable number of requests/second, like ~30 or 40?
For anyone wondering, this is how I ended up doing it. The comment by TroyT was correct that you can batch save your records. This answer includes bonus of queued batches:
let save1 = CKModifyRecordsOperation(recordsToSave: list1, recordIDsToDelete: nil)
let save2 = CKModifyRecordsOperation(recordsToSave: list2, recordIDsToDelete: nil)
save1.database = publicDB
save2.database = publicDB
save2.addDependency(save1)
let queue = NSOperationQueue()
queue.addOperations([save1, save2], waitUntilFinished: false)
save1.modifyRecordsCompletionBlock = { savedRecords, deletedRecordsIDs, error in
if (error != nil){
//handle error
}else{
//data saved
}
}