How to fetch all changes since latest CKServerChangeToken? - ios

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.

Related

Listener event not triggered when document is updated (Google Firestore)

I am struggling to understand why my event listener that I initialize on a document is not being triggered whenever I update the document within the app in a different UIViewController. If I update it manually in Google firebase console, the listener event gets triggered successfully. I am 100% updating the correct document too because I see it get updated when I update it in the app. What I am trying to accomplish is have a running listener on the current user that is logged in and all of their fields so i can just use 1 global singleton variable throughout my app and it will always be up to date with their most current fields (name, last name, profile pic, bio, etc.). One thing I noticed is when i use setData instead of updateData, the listener event gets triggered. For some reason it doesn't with updateData. But i don't want to use setData because it will wipe all the other fields as if it is a new doc. Is there something else I should be doing?
Below is the code that initializes the Listener at the very beginning of the app after the user logs in.
static func InitalizeWhistleListener() {
let currentUser = Auth.auth().currentUser?.uid
let userDocRef = Firestore.firestore().collection("users").document(currentUser!)
WhistleListener.shared.listener = userDocRef.addSnapshotListener { documentSnapshot, error in
guard let document = documentSnapshot else {
print("Error fetching document: \(error!)")
return
}
guard let data = document.data() else {
print("Document data was empty.")
return
}
print("INSIDE LISTENER")
}
}
Below is the code that update's this same document in a different view controller whenever the user updates their profile pic
func uploadProfilePicture(_ image: UIImage) {
guard let uid = currentUser!.UID else { return }
let filePath = "user/\(uid).jpg"
let storageRef = Storage.storage().reference().child(filePath)
guard let imageData = image.jpegData(compressionQuality: 0.75) else { return }
storageRef.putData(imageData) { metadata, error in
if error == nil && metadata != nil {
self.userProfileDoc!.updateData([
"profilePicURL": filePath
]) { err in
if let err = err {
print("Error updating document: \(err)")
} else {
print("Document successfully updated")
}
}
}
}
}
You can use set data with merge true it doesn't wipe any other property only merge to specific one that you declared as like I am only update the name of the user without wiping the age or address
db.collection("User")
.document(id)
.setData(["name":"Zeeshan"],merge: true)
The answer is pretty obvious (and sad at the same time). I was constantly updating the filepath to be the user's UID therefore, it would always be the same and the snapshot wouldn't recognize a difference in the update. It had been some time since I had looked at this code so i forgot this is what it was doing. I was looking past this and simply thinking an update (no matter if it was different from the last or not) would trigger an event. That is not the case! So what I did was append an additional UUID to the user's UID so that it changed.

I can't get array and put in the another array swift from Firebase storage

I want get folders and images from Firebase storage. On this code work all except one moment. I cant append array self.collectionImages in array self.collectionImagesArray. I don't have error but array self.collectionImagesArray is empty
class CollectionViewModel: ObservableObject {
#Published var collectionImagesArray: [[String]] = [[]]
#Published var collectionImages = [""]
init() {
var db = Firestore.firestore()
let storageRef = Storage.storage().reference().child("img")
storageRef.listAll { (result, error) in
if error != nil {
print((error?.localizedDescription)!)
}
for prefixName in result.prefixes {
let storageLocation = String(describing: prefixName)
let storageRefImg = Storage.storage().reference(forURL: storageLocation)
storageRefImg.listAll { (result, error) in
if error != nil {
print((error?.localizedDescription)!)
}
for item in result.items {
// List storage reference
let storageLocation = String(describing: item)
let gsReference = Storage.storage().reference(forURL: storageLocation)
// Fetch the download URL
gsReference.downloadURL { url, error in
if let error = error {
// Handle any errors
print(error)
} else {
// Get the download URL for each item storage location
let img = "\(url?.absoluteString ?? "placeholder")"
self.collectionImages.append(img)
print("\(self.collectionImages)")
}
}
}
self.collectionImagesArray.append(self.collectionImages)
print("\(self.collectionImagesArray)")
}
//
self.collectionImagesArray.append(self.collectionImages)
}
}
}
If i put self.collectionImagesArray.append(self.collectionImages) in closure its works but its not what i want
The problem is caused by the fact that calling downloadURL is an asynchronous operation, since it requires a call to the server. While that call is happening, your main code continues so that the user can continue to use the app. Then when the server returns a value, your closure/completion handler is invoked, which adds the URL to the array. So your print("\(self.collectionImagesArray)") happens before the self.collectionImages.append(img) has ever been called.
You can also see this in the order that the print statements occur in your output. You'll see the full, empty array first, and only then see the print("\(self.collectionImages)") outputs.
The solution for this problem is always the same: you need to make sure you only use the array after all the URLs have been added to it. There are many ways to do this, but a simple one is to check whether your array of URLs is the same length as result.items inside the callback:
...
self.collectionImages.append(img)
if self.collectionImages.count == result.items.count {
self.collectionImagesArray.append(self.collectionImages)
print("\(self.collectionImagesArray)")
}
Also see:
How to wait till download from Firebase Storage is completed before executing a completion swift
Closure returning data before async work is done
Return image from asynchronous call
SwiftUI: View does not update after image changed asynchronous

How to get ObjectID and search for specific ObjectID in CoreData in Swift 5?

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.

CloudKit, after reinstalling an app how do I reset my subscriptions to current status of records?

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.

CKShare "Server record changed" error

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!

Resources