Toggle iCloud sync during runtime - swift

I have a SwiftUI App, where the user can buy with in-app purchases some premium features. One of this features is iCloud sync over more devices. I am using CoreData to save users data.
My persistent container:
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "store name")
let description: NSPersistentStoreDescription? = container.persistentStoreDescriptions.first
let remoteChangeKey: String = "NSPersistentStoreRemoteChangeNotificationOptionKey"
if(description != nil) {
description!.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description!.setOption(true as NSNumber, forKey: remoteChangeKey)
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
My question is how can I toggle on/off cloud sync when the user buy a subscription. I don't want hat the user have to restart the app.
I also want that the user can toggle this setting in the in-app settings.
thanks

Declare your variable as a NSPersistentContainer instead of NSPersistentCloudKitContainer. On launch, if the user has cloud sync, load the cloud kit persistent container, otherwise load the non-cloud kit one.
When the switch is toggled, reload the container, following the same rules. To reload the container, I would add the property to a manager object, in which I would add some methods that reload the the container depending on the user's settings.

Related

Core Data database wiped when using NSPersistentCloudKitContainer

I currently have an application that uses Core Data with Apple's NSPersistentCloudKitContainer and I've a 2 users mentioning they lost their data when updating the app - I'm using Lightweight migrations, and the only factor they have in common is: both had no iCloud Storage left.
After further inspection I've noticed that if I go to Settings > iCloud > Disable it for my app, whenever I open my app again all my data will be gone.
As anyone run into this issue? Is this expected? Any way around it?
For reference, here's my setup code:
self.container = NSPersistentCloudKitContainer(name: "DATABASE_NAME")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
Crashlytics.crashlytics().record(error: error)
}
self.container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
self.container.viewContext.automaticallyMergesChangesFromParent = true
})
You could add a check for availability of iCloud container:
func isICloudContainerAvailable()-> Bool {
if let _ = FileManager.default.ubiquityIdentityToken {
return true
} else {
return false
}
}
Depending on results of the check you could return NSPersistentCloudKitContainer or NSPersistentContainer. You should also turn on NSPersistentHistoryTrackingKey for NSPersistentContainer case:
lazy var persistentContainer: NSPersistentContainer = {
var container: NSPersistentContainer!
if isICloudContainerAvailable() {
container = NSPersistentCloudKitContainer(name: "DATABASE_NAME")
} else {
container = NSPersistentContainer(name: "DATABASE_NAME")
let description = container.persistentStoreDescriptions.first
description?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
}
...
return container
}
This way if you go to Settings > iCloud > Disable it for the app, data still should be available in the app (it will use NSPersistentContainer). If you enable iCloud back, all changes in the app will also sync.
But in case user manually delete iCloud data for the app in Manage Account Storage, data in the app will be lost, as it said in the alert: "This will delete all data for this app stored on this iPhone and in iCloud". I can't find the way to save local data for such case.

Knowing when lightweight migration finished

Setup:
I'm trying to do a lightweight Core Data migration in my app. I added a new Core Data model version and added the new improvements (added 9 attributes to existing entities, 2 new entities, relationships between the new and existing entities).
Issue:
Switching from a build with the old database to the new one causes the app to have no data. But once I quit the app and come back, the data is all there (I'm assuming it was just being migrated).
Question:
Is there a way to know when the Core Data is starting a migration (to let the users know) and when the migration is finished (to refresh their view with the new data)? I've done so much searching this past couple of weeks and have not come with much.
Thank you in advance!
Code:
This is how I set up Core Data (specifically the setupContainer() code):
final class CoreDataManager {
static let sharedManager = CoreDataManager()
lazy var persistentContainer: NSPersistentContainer = {
setupContainer()
}()
private func setupContainer() -> NSPersistentContainer {
useCloudSync = UserDefaults.standard.bool(forKey: UserDefaults.Keys.useCloudSync)
let containerToUse: NSPersistentContainer?
if useCloudSync {
containerToUse = NSPersistentCloudKitContainer(name: "appName")
} else {
containerToUse = NSPersistentContainer(name: "appName")
}
//check if we have a container
guard let container = containerToUse else {
fatalError("Hey Listen! Could not get a container!!")
}
// Enable history tracking and remote notifications
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Hey Listen! ###\(#function): Failed to retrieve a persistent store description.")
}
//be notified of change
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption)
description.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption)
//set tracking history if we're using local container
if !useCloudSync {
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Hey Listen! Couldn't load persistent store. Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.transactionAuthor = appTransactionAuthorName
container.viewContext.automaticallyMergesChangesFromParent = true
//Remote changes notification
NotificationCenter.default.addObserver(
self, selector: #selector(type(of: self).storeRemoteChange(_:)),
name: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator)
return container
}//end of setup container
}
Presumably you're calling loadPersistentStores on your NSPersistentContainer. That method accepts a completion handler (closure). That's where you'll get the callback that the migration has finished (or has failed). And you can tell when the migration is about to begin by adding a call just before loadPersistentStores.
If you create your own NSPersistentStoreDescription instance and add it to the NSPersistentContainer, you can control whether the migration happens and whether it runs synchronously or asynchronously.
See Enabling core data lightweight migration in Swift 3 (particularly answer https://stackoverflow.com/a/53607127/719690) for an example of using the completion handler.
Does the completionHandler of loadPersistentStores of NSPersistentContainer run synchronously? is also useful to understanding the flow.
Working from your code sample, you might revise it to (and I've switch to shortened closure syntax):
container.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
// error path, but should be nicer than just crashing
fatalError("Hey Listen! Couldn't load persistent store. Unresolved error \(error), \(error.userInfo)")
} else {
// happy path
print("unicorns and rainbows, migration succeeded, load the tableview")
}
}
(typed in browser, beware typos)
A Swiftier version would use a guard let on the error checking, but I wanted to minimize changes I made to your sample.

How to change the concurrency type for Core Data in Swift

When you create a SwiftUI core data app, nowadays, Xcode creates this struct.
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
return result
}()
var context:NSManagedObjectContext {
return container.viewContext
}
var container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "PharmaTrac")
container.newBackgroundContext()
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: {(storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
I want to change the context concurrency type to private queue, like in this
var managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
But this way apple creates it, the context is created automatically.
How do I create a context with .privateQueueConcurrencyType?
That code creates an NSPersistentContainer, then gets the viewContext from the container. The container can also create other contexts, if you want. You can either call container.newBackgroundContext() to create a new context like the one you describe (it will use private queue concurrency). You can also use context.performBackgroundTask(_:) to have the container create a new private-queue context on the fly for some background work.

Core Data CloudKit Sync Problem on MacCatalyst

I have the strangest problem. I’ve developed a SwiftUI app for iPhone, iPad and MacCatalyst using Core Data and CloudKit private database to sync the user’s data across all his/her devices.
The problem is that when I make an update on an iOS device (iPhone or iPad), the update syncs across all iOS devices, but not to the Macs. Similarly, updates I make on the Macs, sync across the Macs, but not to the iOS devices.
If I delete the app on the Mac and its associated sqllite database in the app’s associated ~/Library/Container/<myapp_container>/Data subfolder and reinstall the app, only the «Mac data» gets refilled from CloudKit. Likewise if I delete the app on an iOS device, only the «iOS data» arrives from CloudKit. In other words, it behaves as if the MacCatalyst data and the iOS data are stored separately in CloudKit.
Now, if I compile the app onto the Mac (rather than installing an Archive app), the sync from the iOS devices go through. But only once. Subsequent updates are not synced until I compile the app onto the Mac again. Likewise, updates made on the Mac only get synced (once) to the iOS devices when I compile the app on the Mac. Recompiling to the iOS devices does not make any difference.
The PersistentCloudkitContainer class:
public class PersistentCloudKitContainer {
// MARK: - Define Constants / Variables
public static var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
// MARK: - Initializer
private init() {}
// MARK: - Core Data stack
public static var persistentContainer: NSPersistentContainer = {
let container = NSPersistentCloudKitContainer(name: "MyModel")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
guard let description = container.persistentStoreDescriptions.first else {
fatalError("### PersistentCloudKitContainer->\(#function): Failed to retrieve persistant store description")
}
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.cloudKitContainerOptions?.databaseScope = .private
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
// MARK: - Core Data Saving support
public static func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
The persistent container is called by the SwiftUI App struct:
struct MyApp: App {
let context = PersistentCloudKitContainer.persistentContainer.viewContext
var body: some Scene {
WindowGroup {
ContentView().environment(\.managedObjectContext, context)
}
}
}

Updated core data model without new version, how to fix?

I was working on an app update and I added and deleted some attributes and entities to my data model without creating a new version of it. It was my first app and unfortunately I was not using any version control system neither.
I have tried to delete the old sqlite file and recreate a new one when user updated the app but it crashes immediately after launch because one of the new attributes returns nil which I added recently.
I have a sign up screen so I thought I could show that screen again when app updated and fill my model with new data but that doesnt work either. I am compeletely lost as a beginner.
Scene Delegate
if !UserDefaults.standard.bool(forKey: K.DefaultKey.firstLaunch) {
UserDefaults.standard.set(true, forKey: K.DefaultKey.firstLaunch)
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyboard.instantiateViewController(withIdentifier: "onboard")
self.window?.rootViewController = viewController
self.window?.makeKeyAndVisible()
}
My code to delete and rebuild
class CoreData {
static let datamodelName = "DataModel"
static let storeType = "sqlite"
static let persistentContainer = NSPersistentContainer(name: datamodelName)
private static let url: URL = {
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("\(datamodelName).\(storeType)")
assert(FileManager.default.fileExists(atPath: url.path))
return url
}()
static func deleteAndRebuild() {
try! persistentContainer.persistentStoreCoordinator.destroyPersistentStore(at: url, ofType: storeType, options: nil)
persistentContainer.loadPersistentStores(completionHandler: { (nsPersistentStoreDescription, error) in
guard let error = error else {
return
}
fatalError(error.localizedDescription)
})
}
}
Appdelegate
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "DataModel")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
CoreDataContext.deleteAndRebuild()
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
Edit: I found hash codes of the old model via momd file. Can I use these hash codes to recreate old data model?
If possible, the best way to fix this would be to undo the model changes, so that it would be possible to load the current data. Then make the changes again properly so that migration could happen. You'd do something like
Start using source control.
Undo the recent changes (I know you don't have version control but you probably know what they were).
Verify step 1 by running the app using only that version of the model and making sure it loads data without crashing.
Create a new model version, make it the current version, and make your changes there. Core Data will then try to migrate the data to the new model.
[Update] The reason you're still getting a crash with your delete-and-rebuild code is that you're still calling fatalError after deleting and rebuilding the persistent store. If you've successfully recovered, you shouldn't also call fatalError, since its whole purpose is to crash the app. I don't know what you mean about one of the new attributes returning nil. That's expected of a new attribute, because since it's new, there's no data for it.