diff --git a/.gitignore b/.gitignore index f1933103..67cbb590 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots fastlane/test_output +.DS_Store diff --git a/.swift-version b/.swift-version deleted file mode 100644 index 5186d070..00000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -4.0 diff --git a/.travis.yml b/.travis.yml index a60a057b..f7237cce 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ -osx_image: xcode9.2 -language: objective-c +osx_image: xcode12.5 +language: swift podfile: "Example/Podfile" branches: @@ -8,7 +8,7 @@ branches: env: - DESTINATION='platform=OS X' POD_LINT="YES" - - DESTINATION='platform=iOS Simulator,name=iPhone 6S' BUILD_EXAMPLE="YES" + - DESTINATION='platform=iOS Simulator,name=iPhone XS' BUILD_EXAMPLE="YES" - DESTINATION='platform=watchOS Simulator,name=Apple Watch - 38mm' SKIP_TEST="YES" - DESTINATION='platform=tvOS Simulator,name=Apple TV 4K' diff --git a/CloudCore.podspec b/CloudCore.podspec index 342335bd..16910e01 100755 --- a/CloudCore.podspec +++ b/CloudCore.podspec @@ -1,25 +1,26 @@ Pod::Spec.new do |s| s.name = "CloudCore" - s.summary = "Framework that enables synchronization between CloudKit (iCloud) and Core Data. Can be used as CloudKit caching mechanism." - s.version = "2.0.1" - s.homepage = "https://github.com/sorix/CloudCore" + s.summary = "Framework that enables synchronization between CloudKit and Core Data." + s.version = "5.1.0" + s.homepage = "https://github.com/deeje/CloudCore" s.license = 'MIT' - s.author = { "Vasily Ulianov" => "vasily@me.com" } + s.author = { "deeje" => "deeje@mac.com", "Vasily Ulianov" => "vasily@me.com" } s.source = { - :git => "https://github.com/sorix/CloudCore.git", + :git => "https://github.com/deeje/CloudCore.git", :tag => s.version.to_s } - s.ios.deployment_target = '10.0' - s.osx.deployment_target = '10.12' - s.tvos.deployment_target = '10.0' - s.watchos.deployment_target = '3.0' + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '11.0' + s.tvos.deployment_target = '12.0' + s.watchos.deployment_target = '6.0' s.source_files = 'Source/**/*.swift' s.ios.frameworks = 'Foundation', 'CloudKit', 'CoreData' s.osx.frameworks = 'Foundation', 'CloudKit', 'CoreData' + s.tvos.frameworks = 'Foundation', 'CloudKit', 'CoreData' + s.watchos.frameworks = 'Foundation', 'CloudKit', 'CoreData' - s.pod_target_xcconfig = { 'SWIFT_VERSION' => '4.0' } - s.documentation_url = 'http://cocoadocs.org/docsets/CloudCore/' + s.swift_versions = [5.1] end diff --git a/CloudCore.xcodeproj/project.pbxproj b/CloudCore.xcodeproj/project.pbxproj old mode 100755 new mode 100644 index 6a51003b..612c64e1 --- a/CloudCore.xcodeproj/project.pbxproj +++ b/CloudCore.xcodeproj/project.pbxproj @@ -7,13 +7,27 @@ objects = { /* Begin PBXBuildFile section */ + 570D8D23280631F900E6836A /* DeleteCloudCoreZoneOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570D8D22280631F900E6836A /* DeleteCloudCoreZoneOperation.swift */; }; + 57505AB021A7591500D9CF8F /* PullResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57505AAF21A7591500D9CF8F /* PullResult.swift */; }; + 575ADF462655AB7C0050D693 /* PullRecordOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575ADF442655AB7C0050D693 /* PullRecordOperation.swift */; }; + 575ADF472655AB7C0050D693 /* PullChangesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575ADF452655AB7C0050D693 /* PullChangesOperation.swift */; }; + 5763BF7E280B427900B2CCCD /* CloudCoreSharingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF7C280B427900B2CCCD /* CloudCoreSharingController.swift */; }; + 5763BF7F280B427900B2CCCD /* CloudCoreSharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF7D280B427900B2CCCD /* CloudCoreSharing.swift */; }; + 5763BF8B280B42F400B2CCCD /* CloudKitSharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF89280B42F400B2CCCD /* CloudKitSharing.swift */; }; + 5763BF8C280B42F400B2CCCD /* CloudCoreType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF8A280B42F400B2CCCD /* CloudCoreType.swift */; }; + 5763BF8F280B430C00B2CCCD /* NSManagedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF8D280B430C00B2CCCD /* NSManagedContainer.swift */; }; + 5763BF90280B430C00B2CCCD /* UIViewController+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF8E280B430C00B2CCCD /* UIViewController+CloudKit.swift */; }; + 5763BF9F280B490200B2CCCD /* DownloadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF9B280B490200B2CCCD /* DownloadOperation.swift */; }; + 5763BFA0280B490200B2CCCD /* CloudCoreCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF9C280B490200B2CCCD /* CloudCoreCacheManager.swift */; }; + 5763BFA1280B490200B2CCCD /* CloudCoreCacheable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF9D280B490200B2CCCD /* CloudCoreCacheable.swift */; }; + 5763BFA2280B490200B2CCCD /* UploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5763BF9E280B490200B2CCCD /* UploadOperation.swift */; }; D9089D4A1FE14E57000FC60C /* SetupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9089D491FE14E57000FC60C /* SetupOperation.swift */; }; D97465F81FE319930060EA66 /* CloudCoreDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97465F71FE319930060EA66 /* CloudCoreDelegate.swift */; }; D97465FA1FE31A650060EA66 /* Module.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97465F91FE31A650060EA66 /* Module.swift */; }; D985DE9D1FDFF9D400236870 /* PurgeLocalDatabaseOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985DE9C1FDFF9D400236870 /* PurgeLocalDatabaseOperation.swift */; }; D985DEA41FE026D400236870 /* CreateCloudCoreZoneOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985DEA31FE026D400236870 /* CreateCloudCoreZoneOperation.swift */; }; D985DEA81FE0292000236870 /* SubscribeOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985DEA71FE0292000236870 /* SubscribeOperation.swift */; }; - D985DEAB1FE0335800236870 /* UploadAllLocalDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985DEAA1FE0335800236870 /* UploadAllLocalDataOperation.swift */; }; + D985DEAB1FE0335800236870 /* PushAllLocalDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985DEAA1FE0335800236870 /* PushAllLocalDataOperation.swift */; }; D985DEAE1FE034A900236870 /* NSManagedObjectModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985DEAD1FE034A900236870 /* NSManagedObjectModel.swift */; }; D9B3C6F61FCEF38D00CDB7FF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B3C6F51FCEF38D00CDB7FF /* AppDelegate.swift */; }; D9B3C6F81FCEF38D00CDB7FF /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B3C6F71FCEF38D00CDB7FF /* ViewController.swift */; }; @@ -37,20 +51,16 @@ E20A73CC1E68608100A6851A /* RecordToCoreDataOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20A73CB1E68608100A6851A /* RecordToCoreDataOperationTests.swift */; }; E21FA03E1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21FA03D1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift */; }; E22A53DA1E4A8743009286C0 /* CloudKitAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22A53D91E4A8743009286C0 /* CloudKitAttribute.swift */; }; - E22C40461E42956C009469A1 /* CoreDataListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22C40451E42956C009469A1 /* CoreDataListener.swift */; }; - E23C478C1E48A404004310F9 /* CloudSaveOperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C478B1E48A404004310F9 /* CloudSaveOperationQueue.swift */; }; - E247EF8D1E67775500EBD75E /* ErrorBlockProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF8B1E67773F00EBD75E /* ErrorBlockProxyTests.swift */; }; + E22C40461E42956C009469A1 /* CoreDataObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22C40451E42956C009469A1 /* CoreDataObserver.swift */; }; + E23C478C1E48A404004310F9 /* PushOperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C478B1E48A404004310F9 /* PushOperationQueue.swift */; }; E247EF971E67873E00EBD75E /* DeleteFromCoreDataOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF951E67873900EBD75E /* DeleteFromCoreDataOperationTests.swift */; }; E247EF9A1E678EAC00EBD75E /* CustomFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF981E678EA200EBD75E /* CustomFunctions.swift */; }; E24F44A61E4595B900F78819 /* CoreDataRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24F44A51E4595B900F78819 /* CoreDataRelationship.swift */; }; - E2564BFF1E5061BC002E518B /* ErrorBlockProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2564BFE1E5061BC002E518B /* ErrorBlockProxy.swift */; }; E28F0B931E671E7400BF532A /* CKRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0B911E671E6500BF532A /* CKRecordTests.swift */; }; - E28F0B9F1E67245A00BF532A /* CKRecordIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0B9D1E67245600BF532A /* CKRecordIDTests.swift */; }; E28F0BA21E67260900BF532A /* NSEntityDescriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0BA01E6725E700BF532A /* NSEntityDescriptionTests.swift */; }; E28F0BA31E67280100BF532A /* NSManagedObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24F44A81E459E3E00F78819 /* NSManagedObjectTests.swift */; }; E29BB21A1E4334590020F5B6 /* CloudCoreConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2191E4334590020F5B6 /* CloudCoreConfig.swift */; }; E29BB21C1E43381D0020F5B6 /* CloudCoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB21B1E43381D0020F5B6 /* CloudCoreError.swift */; }; - E29BB21E1E433E050020F5B6 /* CKRecordID.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB21D1E433E050020F5B6 /* CKRecordID.swift */; }; E29BB2211E4344E80020F5B6 /* CKRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2201E4344E80020F5B6 /* CKRecord.swift */; }; E29BB2231E4346FF0020F5B6 /* NSManagedObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2221E4346FF0020F5B6 /* NSManagedObject.swift */; }; E29BB22D1E436F310020F5B6 /* CloudCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D5B2E89F1C3A780C00C0327D /* CloudCore.framework */; }; @@ -62,10 +72,9 @@ E2C02A0E1E4C99AD001B2871 /* ObjectToRecordConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A0D1E4C99AD001B2871 /* ObjectToRecordConverter.swift */; }; E2C02A141E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A131E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift */; }; E2C02A191E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A181E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift */; }; - E2C3A6D11E4A8EAF009151F3 /* FetchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C3A6D01E4A8EAF009151F3 /* FetchResult.swift */; }; E2D390081E4A49350019BBCD /* NSEntityDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D390071E4A49350019BBCD /* NSEntityDescription.swift */; }; E2E296CA1E49DA0800E7D6ED /* Tokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E296C91E49DA0800E7D6ED /* Tokens.swift */; }; - E2E4D8411E76D5A600550CBE /* FetchAndSaveOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4D83D1E76D4EF00550CBE /* FetchAndSaveOperation.swift */; }; + E2E4D8411E76D5A600550CBE /* PullOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4D83D1E76D4EF00550CBE /* PullOperation.swift */; }; E2EE20071E4E6DCE0060F769 /* ServiceAttributeName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EE20061E4E6DCE0060F769 /* ServiceAttributeName.swift */; }; E2FA74441E769BF900C3489D /* RecordWithDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FA74431E769BF900C3489D /* RecordWithDatabase.swift */; }; E2FA74481E769D9400C3489D /* RecordIDWithDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FA74471E769D9400C3489D /* RecordIDWithDatabase.swift */; }; @@ -89,6 +98,22 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 245F765CC7CBF0507158B4A9 /* Pods_CloudCoreTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_CloudCoreTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 570D8D22280631F900E6836A /* DeleteCloudCoreZoneOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteCloudCoreZoneOperation.swift; sourceTree = ""; }; + 57505AAF21A7591500D9CF8F /* PullResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PullResult.swift; sourceTree = ""; }; + 575ADF442655AB7C0050D693 /* PullRecordOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PullRecordOperation.swift; sourceTree = ""; }; + 575ADF452655AB7C0050D693 /* PullChangesOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PullChangesOperation.swift; sourceTree = ""; }; + 5763BF7C280B427900B2CCCD /* CloudCoreSharingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreSharingController.swift; sourceTree = ""; }; + 5763BF7D280B427900B2CCCD /* CloudCoreSharing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreSharing.swift; sourceTree = ""; }; + 5763BF89280B42F400B2CCCD /* CloudKitSharing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitSharing.swift; sourceTree = ""; }; + 5763BF8A280B42F400B2CCCD /* CloudCoreType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreType.swift; sourceTree = ""; }; + 5763BF8D280B430C00B2CCCD /* NSManagedContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedContainer.swift; sourceTree = ""; }; + 5763BF8E280B430C00B2CCCD /* UIViewController+CloudKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+CloudKit.swift"; sourceTree = ""; }; + 5763BF9B280B490200B2CCCD /* DownloadOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadOperation.swift; sourceTree = ""; }; + 5763BF9C280B490200B2CCCD /* CloudCoreCacheManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreCacheManager.swift; sourceTree = ""; }; + 5763BF9D280B490200B2CCCD /* CloudCoreCacheable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreCacheable.swift; sourceTree = ""; }; + 5763BF9E280B490200B2CCCD /* UploadOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadOperation.swift; sourceTree = ""; }; + 8EAC4D8B1B0EF4ECE4BDA160 /* Pods_CloudCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_CloudCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D5B2E89F1C3A780C00C0327D /* CloudCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CloudCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D5C6298B1C3A8BBD007F7B7C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D9089D491FE14E57000FC60C /* SetupOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupOperation.swift; sourceTree = ""; }; @@ -97,7 +122,7 @@ D985DE9C1FDFF9D400236870 /* PurgeLocalDatabaseOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeLocalDatabaseOperation.swift; sourceTree = ""; }; D985DEA31FE026D400236870 /* CreateCloudCoreZoneOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateCloudCoreZoneOperation.swift; sourceTree = ""; }; D985DEA71FE0292000236870 /* SubscribeOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeOperation.swift; sourceTree = ""; }; - D985DEAA1FE0335800236870 /* UploadAllLocalDataOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadAllLocalDataOperation.swift; sourceTree = ""; }; + D985DEAA1FE0335800236870 /* PushAllLocalDataOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushAllLocalDataOperation.swift; sourceTree = ""; }; D985DEAD1FE034A900236870 /* NSManagedObjectModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectModel.swift; sourceTree = ""; }; D9B3C6F31FCEF38D00CDB7FF /* TestableApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestableApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; D9B3C6F51FCEF38D00CDB7FF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -123,23 +148,19 @@ E20A73CB1E68608100A6851A /* RecordToCoreDataOperationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordToCoreDataOperationTests.swift; sourceTree = ""; }; E21FA03D1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordToCoreDataOperation.swift; sourceTree = ""; }; E22A53D91E4A8743009286C0 /* CloudKitAttribute.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitAttribute.swift; sourceTree = ""; }; - E22C40441E4291FB009469A1 /* CloudCore.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = CloudCore.podspec; sourceTree = ""; }; - E22C40451E42956C009469A1 /* CoreDataListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataListener.swift; sourceTree = ""; }; - E23C478B1E48A404004310F9 /* CloudSaveOperationQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudSaveOperationQueue.swift; sourceTree = ""; }; - E247EF8B1E67773F00EBD75E /* ErrorBlockProxyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorBlockProxyTests.swift; sourceTree = ""; }; + E22C40441E4291FB009469A1 /* CloudCore.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = CloudCore.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + E22C40451E42956C009469A1 /* CoreDataObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataObserver.swift; sourceTree = ""; }; + E23C478B1E48A404004310F9 /* PushOperationQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushOperationQueue.swift; sourceTree = ""; }; E247EF951E67873900EBD75E /* DeleteFromCoreDataOperationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteFromCoreDataOperationTests.swift; sourceTree = ""; }; E247EF981E678EA200EBD75E /* CustomFunctions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomFunctions.swift; sourceTree = ""; }; E24F44A51E4595B900F78819 /* CoreDataRelationship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataRelationship.swift; sourceTree = ""; }; E24F44A81E459E3E00F78819 /* NSManagedObjectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectTests.swift; sourceTree = ""; }; - E2564BFE1E5061BC002E518B /* ErrorBlockProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorBlockProxy.swift; sourceTree = ""; }; E277DB061E7726FB00DC334A /* PublicDatabaseSubscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PublicDatabaseSubscriptions.swift; sourceTree = ""; }; E277DB0C1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchPublicSubscriptionsOperation.swift; sourceTree = ""; }; E28F0B911E671E6500BF532A /* CKRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecordTests.swift; sourceTree = ""; }; - E28F0B9D1E67245600BF532A /* CKRecordIDTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecordIDTests.swift; sourceTree = ""; }; E28F0BA01E6725E700BF532A /* NSEntityDescriptionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSEntityDescriptionTests.swift; sourceTree = ""; }; E29BB2191E4334590020F5B6 /* CloudCoreConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreConfig.swift; sourceTree = ""; }; E29BB21B1E43381D0020F5B6 /* CloudCoreError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreError.swift; sourceTree = ""; }; - E29BB21D1E433E050020F5B6 /* CKRecordID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecordID.swift; sourceTree = ""; }; E29BB2201E4344E80020F5B6 /* CKRecord.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecord.swift; sourceTree = ""; }; E29BB2221E4346FF0020F5B6 /* NSManagedObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObject.swift; sourceTree = ""; }; E29BB2281E436F310020F5B6 /* CloudCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -152,10 +173,9 @@ E2C02A0D1E4C99AD001B2871 /* ObjectToRecordConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjectToRecordConverter.swift; sourceTree = ""; }; E2C02A131E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchRecordZoneChangesOperation.swift; sourceTree = ""; }; E2C02A181E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteFromCoreDataOperation.swift; sourceTree = ""; }; - E2C3A6D01E4A8EAF009151F3 /* FetchResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchResult.swift; sourceTree = ""; }; E2D390071E4A49350019BBCD /* NSEntityDescription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSEntityDescription.swift; sourceTree = ""; }; E2E296C91E49DA0800E7D6ED /* Tokens.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tokens.swift; sourceTree = ""; }; - E2E4D83D1E76D4EF00550CBE /* FetchAndSaveOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchAndSaveOperation.swift; sourceTree = ""; }; + E2E4D83D1E76D4EF00550CBE /* PullOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PullOperation.swift; sourceTree = ""; }; E2EE20061E4E6DCE0060F769 /* ServiceAttributeName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceAttributeName.swift; sourceTree = ""; }; E2FA74431E769BF900C3489D /* RecordWithDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordWithDatabase.swift; sourceTree = ""; }; E2FA74471E769D9400C3489D /* RecordIDWithDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordIDWithDatabase.swift; sourceTree = ""; }; @@ -196,6 +216,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5763BF7B280B427900B2CCCD /* Sharing */ = { + isa = PBXGroup; + children = ( + 5763BF7D280B427900B2CCCD /* CloudCoreSharing.swift */, + 5763BF7C280B427900B2CCCD /* CloudCoreSharingController.swift */, + ); + path = Sharing; + sourceTree = ""; + }; + 5763BF9A280B490200B2CCCD /* Caching */ = { + isa = PBXGroup; + children = ( + 5763BF9D280B490200B2CCCD /* CloudCoreCacheable.swift */, + 5763BF9C280B490200B2CCCD /* CloudCoreCacheManager.swift */, + 5763BF9E280B490200B2CCCD /* UploadOperation.swift */, + 5763BF9B280B490200B2CCCD /* DownloadOperation.swift */, + ); + path = Caching; + sourceTree = ""; + }; D5B2E8951C3A780C00C0327D = { isa = PBXGroup; children = ( @@ -244,15 +284,16 @@ path = CloudCoreTests; sourceTree = ""; }; - D9089D481FE14E4A000FC60C /* Setup Operation */ = { + D9089D481FE14E4A000FC60C /* Setup */ = { isa = PBXGroup; children = ( D9089D491FE14E57000FC60C /* SetupOperation.swift */, D985DEA31FE026D400236870 /* CreateCloudCoreZoneOperation.swift */, - D985DEAA1FE0335800236870 /* UploadAllLocalDataOperation.swift */, D985DEA71FE0292000236870 /* SubscribeOperation.swift */, + 570D8D22280631F900E6836A /* DeleteCloudCoreZoneOperation.swift */, + D985DEAA1FE0335800236870 /* PushAllLocalDataOperation.swift */, ); - path = "Setup Operation"; + path = Setup; sourceTree = ""; }; D9B3C6F41FCEF38D00CDB7FF /* App */ = { @@ -295,6 +336,8 @@ isa = PBXGroup; children = ( D9B3C7331FCEFD9100CDB7FF /* CloudKit.framework */, + 8EAC4D8B1B0EF4ECE4BDA160 /* Pods_CloudCore.framework */, + 245F765CC7CBF0507158B4A9 /* Pods_CloudCoreTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -311,35 +354,38 @@ E2075FF11E4BB6EF00E31F1F /* Classes */ = { isa = PBXGroup; children = ( - D9089D481FE14E4A000FC60C /* Setup Operation */, E2075FF81E4BBEAC00E31F1F /* AsynchronousOperation.swift */, - E2075FF31E4BB70D00E31F1F /* Fetch */, - E2075FF21E4BB6F700E31F1F /* Save */, E200D44C1E48E13200B707D4 /* CloudCore.swift */, - E2564BFE1E5061BC002E518B /* ErrorBlockProxy.swift */, + D9089D481FE14E4A000FC60C /* Setup */, + E2075FF21E4BB6F700E31F1F /* Push */, + E2075FF31E4BB70D00E31F1F /* Pull */, + 5763BF7B280B427900B2CCCD /* Sharing */, + 5763BF9A280B490200B2CCCD /* Caching */, ); path = Classes; sourceTree = ""; }; - E2075FF21E4BB6F700E31F1F /* Save */ = { + E2075FF21E4BB6F700E31F1F /* Push */ = { isa = PBXGroup; children = ( + E22C40451E42956C009469A1 /* CoreDataObserver.swift */, + E23C478B1E48A404004310F9 /* PushOperationQueue.swift */, E2FA74461E769D8700C3489D /* Model */, E288C5751E4C9519002360A1 /* ObjectToRecord */, - E23C478B1E48A404004310F9 /* CloudSaveOperationQueue.swift */, - E22C40451E42956C009469A1 /* CoreDataListener.swift */, ); - path = Save; + path = Push; sourceTree = ""; }; - E2075FF31E4BB70D00E31F1F /* Fetch */ = { + E2075FF31E4BB70D00E31F1F /* Pull */ = { isa = PBXGroup; children = ( - E277DB0F1E77FC9F00DC334A /* PublicSubscriptions */, + E2E4D83D1E76D4EF00550CBE /* PullOperation.swift */, + 575ADF452655AB7C0050D693 /* PullChangesOperation.swift */, + 575ADF442655AB7C0050D693 /* PullRecordOperation.swift */, E2C02A171E4CDEDA001B2871 /* SubOperations */, - E2E4D83D1E76D4EF00550CBE /* FetchAndSaveOperation.swift */, + E277DB0F1E77FC9F00DC334A /* PublicSubscriptions */, ); - path = Fetch; + path = Pull; sourceTree = ""; }; E23C47871E487CEA004310F9 /* Model */ = { @@ -354,6 +400,8 @@ isa = PBXGroup; children = ( D97465F71FE319930060EA66 /* CloudCoreDelegate.swift */, + 5763BF8A280B42F400B2CCCD /* CloudCoreType.swift */, + 5763BF89280B42F400B2CCCD /* CloudKitSharing.swift */, ); path = Protocols; sourceTree = ""; @@ -361,19 +409,18 @@ E247EF8A1E67771C00EBD75E /* Classes */ = { isa = PBXGroup; children = ( - E247EF8E1E677D1400EBD75E /* Fetch */, - E29D11771E69808800E3DCBF /* Upload */, - E247EF8B1E67773F00EBD75E /* ErrorBlockProxyTests.swift */, + E247EF8E1E677D1400EBD75E /* Pull */, + E29D11771E69808800E3DCBF /* Push */, ); path = Classes; sourceTree = ""; }; - E247EF8E1E677D1400EBD75E /* Fetch */ = { + E247EF8E1E677D1400EBD75E /* Pull */ = { isa = PBXGroup; children = ( E247EF8F1E677D1B00EBD75E /* Operations */, ); - path = Fetch; + path = Pull; sourceTree = ""; }; E247EF8F1E677D1B00EBD75E /* Operations */ = { @@ -408,7 +455,6 @@ E28F0B9C1E67244A00BF532A /* Extensions */ = { isa = PBXGroup; children = ( - E28F0B9D1E67245600BF532A /* CKRecordIDTests.swift */, E28F0BA01E6725E700BF532A /* NSEntityDescriptionTests.swift */, E24F44A81E459E3E00F78819 /* NSManagedObjectTests.swift */, ); @@ -418,7 +464,8 @@ E29BB21F1E433FDA0020F5B6 /* Extensions */ = { isa = PBXGroup; children = ( - E29BB21D1E433E050020F5B6 /* CKRecordID.swift */, + 5763BF8D280B430C00B2CCCD /* NSManagedContainer.swift */, + 5763BF8E280B430C00B2CCCD /* UIViewController+CloudKit.swift */, D985DEAD1FE034A900236870 /* NSManagedObjectModel.swift */, E2D390071E4A49350019BBCD /* NSEntityDescription.swift */, E29BB2221E4346FF0020F5B6 /* NSManagedObject.swift */, @@ -436,12 +483,12 @@ path = Tests; sourceTree = ""; }; - E29D11771E69808800E3DCBF /* Upload */ = { + E29D11771E69808800E3DCBF /* Push */ = { isa = PBXGroup; children = ( E29D11781E69810F00E3DCBF /* ObjectToRecord */, ); - path = Upload; + path = Push; sourceTree = ""; }; E29D11781E69810F00E3DCBF /* ObjectToRecord */ = { @@ -469,7 +516,7 @@ isa = PBXGroup; children = ( E29BB21B1E43381D0020F5B6 /* CloudCoreError.swift */, - E2C3A6D01E4A8EAF009151F3 /* FetchResult.swift */, + 57505AAF21A7591500D9CF8F /* PullResult.swift */, D97465F91FE31A650060EA66 /* Module.swift */, ); path = Enum; @@ -595,7 +642,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0910; - LastUpgradeCheck = 0900; + LastUpgradeCheck = 1330; ORGANIZATIONNAME = "Vasily Ulianov"; TargetAttributes = { D5B2E89E1C3A780C00C0327D = { @@ -605,7 +652,7 @@ }; D9B3C6F21FCEF38D00CDB7FF = { CreatedOnToolsVersion = 9.1; - DevelopmentTeam = 7X2PJ6H6YM; + LastSwiftMigration = 1010; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.Push = { @@ -619,19 +666,20 @@ D9B3C71B1FCEF96D00CDB7FF = { CreatedOnToolsVersion = 9.1; DevelopmentTeam = 7X2PJ6H6YM; + LastSwiftMigration = 1010; ProvisioningStyle = Automatic; TestTargetID = D9B3C6F21FCEF38D00CDB7FF; }; E29BB2271E436F310020F5B6 = { CreatedOnToolsVersion = 8.2.1; - LastSwiftMigration = 0900; + LastSwiftMigration = 1030; ProvisioningStyle = Automatic; }; }; }; buildConfigurationList = D5B2E8991C3A780C00C0327D /* Build configuration list for PBXProject "CloudCore" */; compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; + developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, @@ -689,34 +737,45 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5763BF8F280B430C00B2CCCD /* NSManagedContainer.swift in Sources */, E21FA03E1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift in Sources */, D97465F81FE319930060EA66 /* CloudCoreDelegate.swift in Sources */, - E2E4D8411E76D5A600550CBE /* FetchAndSaveOperation.swift in Sources */, + E2E4D8411E76D5A600550CBE /* PullOperation.swift in Sources */, E2C02A141E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift in Sources */, + 5763BF7E280B427900B2CCCD /* CloudCoreSharingController.swift in Sources */, E2C02A191E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift in Sources */, + 570D8D23280631F900E6836A /* DeleteCloudCoreZoneOperation.swift in Sources */, E29BB21A1E4334590020F5B6 /* CloudCoreConfig.swift in Sources */, E2EE20071E4E6DCE0060F769 /* ServiceAttributeName.swift in Sources */, - E29BB21E1E433E050020F5B6 /* CKRecordID.swift in Sources */, + 5763BF7F280B427900B2CCCD /* CloudCoreSharing.swift in Sources */, D985DEAE1FE034A900236870 /* NSManagedObjectModel.swift in Sources */, - E23C478C1E48A404004310F9 /* CloudSaveOperationQueue.swift in Sources */, + E23C478C1E48A404004310F9 /* PushOperationQueue.swift in Sources */, + 5763BF90280B430C00B2CCCD /* UIViewController+CloudKit.swift in Sources */, E2FA74441E769BF900C3489D /* RecordWithDatabase.swift in Sources */, - E2C3A6D11E4A8EAF009151F3 /* FetchResult.swift in Sources */, - E22C40461E42956C009469A1 /* CoreDataListener.swift in Sources */, + E22C40461E42956C009469A1 /* CoreDataObserver.swift in Sources */, E2075FF91E4BBEAC00E31F1F /* AsynchronousOperation.swift in Sources */, E24F44A61E4595B900F78819 /* CoreDataRelationship.swift in Sources */, D9089D4A1FE14E57000FC60C /* SetupOperation.swift in Sources */, D97465FA1FE31A650060EA66 /* Module.swift in Sources */, + 57505AB021A7591500D9CF8F /* PullResult.swift in Sources */, + 5763BFA1280B490200B2CCCD /* CloudCoreCacheable.swift in Sources */, + 575ADF472655AB7C0050D693 /* PullChangesOperation.swift in Sources */, E29BB2371E4377F80020F5B6 /* CoreDataAttribute.swift in Sources */, E2E296CA1E49DA0800E7D6ED /* Tokens.swift in Sources */, + 575ADF462655AB7C0050D693 /* PullRecordOperation.swift in Sources */, E2075FFF1E4BCD7E00E31F1F /* ObjectToRecordOperation.swift in Sources */, - E2564BFF1E5061BC002E518B /* ErrorBlockProxy.swift in Sources */, D985DEA41FE026D400236870 /* CreateCloudCoreZoneOperation.swift in Sources */, - D985DEAB1FE0335800236870 /* UploadAllLocalDataOperation.swift in Sources */, + 5763BFA0280B490200B2CCCD /* CloudCoreCacheManager.swift in Sources */, + D985DEAB1FE0335800236870 /* PushAllLocalDataOperation.swift in Sources */, + 5763BFA2280B490200B2CCCD /* UploadOperation.swift in Sources */, + 5763BF8C280B42F400B2CCCD /* CloudCoreType.swift in Sources */, E2C02A0E1E4C99AD001B2871 /* ObjectToRecordConverter.swift in Sources */, D985DE9D1FDFF9D400236870 /* PurgeLocalDatabaseOperation.swift in Sources */, E2FA74481E769D9400C3489D /* RecordIDWithDatabase.swift in Sources */, + 5763BF9F280B490200B2CCCD /* DownloadOperation.swift in Sources */, E29BB2211E4344E80020F5B6 /* CKRecord.swift in Sources */, E2D390081E4A49350019BBCD /* NSEntityDescription.swift in Sources */, + 5763BF8B280B42F400B2CCCD /* CloudKitSharing.swift in Sources */, E29BB2231E4346FF0020F5B6 /* NSManagedObject.swift in Sources */, E200D44D1E48E13200B707D4 /* CloudCore.swift in Sources */, D985DEA81FE0292000236870 /* SubscribeOperation.swift in Sources */, @@ -760,12 +819,10 @@ E29D117A1E69813F00E3DCBF /* CoreDataAttributeTests.swift in Sources */, E28F0B931E671E7400BF532A /* CKRecordTests.swift in Sources */, E29BB2351E436F720020F5B6 /* model.xcdatamodeld in Sources */, - E28F0B9F1E67245A00BF532A /* CKRecordIDTests.swift in Sources */, E247EF971E67873E00EBD75E /* DeleteFromCoreDataOperationTests.swift in Sources */, E29D117D1E69A47700E3DCBF /* CoreDataRelationshipTests.swift in Sources */, D9B3C7391FCF0C9E00CDB7FF /* CorrectObject.swift in Sources */, E247EF9A1E678EAC00EBD75E /* CustomFunctions.swift in Sources */, - E247EF8D1E67775500EBD75E /* ErrorBlockProxyTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -816,14 +873,17 @@ CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -849,16 +909,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.12; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SUPPORTED_PLATFORMS = "macosx watchsimulator watchos appletvsimulator appletvos iphonesimulator iphoneos"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 3.0; TARGETED_DEVICE_FAMILY = "1,2,3,4"; + TVOS_DEPLOYMENT_TARGET = 12.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Debug; }; @@ -874,14 +936,17 @@ CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -901,22 +966,25 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.12; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SUPPORTED_PLATFORMS = "macosx watchsimulator watchos appletvsimulator appletvos iphonesimulator iphoneos"; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_VERSION = 3.0; TARGETED_DEVICE_FAMILY = "1,2,3,4"; + TVOS_DEPLOYMENT_TARGET = 12.0; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WATCHOS_DEPLOYMENT_TARGET = 6.0; }; name = Release; }; D5B2E8B41C3A780C00C0327D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; @@ -933,15 +1001,14 @@ SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_SWIFT3_OBJC_INFERENCE = Off; - SWIFT_VERSION = 4.0; - TVOS_DEPLOYMENT_TARGET = 10.0; - WATCHOS_DEPLOYMENT_TARGET = 3.0; + SWIFT_VERSION = 5.0; }; name = Debug; }; D5B2E8B51C3A780C00C0327D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; @@ -957,9 +1024,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_SWIFT3_OBJC_INFERENCE = Off; - SWIFT_VERSION = 4.0; - TVOS_DEPLOYMENT_TARGET = 10.0; - WATCHOS_DEPLOYMENT_TARGET = 3.0; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -975,16 +1040,14 @@ CODE_SIGN_ENTITLEMENTS = Tests/CloudKitTests/App/TestableApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 7X2PJ6H6YM; + DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_SYMBOLS_PRIVATE_EXTERN = NO; INFOPLIST_FILE = Tests/CloudKitTests/App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.1; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = cloudtests.TestableApp; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1001,15 +1064,13 @@ CODE_SIGN_ENTITLEMENTS = Tests/CloudKitTests/App/TestableApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 7X2PJ6H6YM; + DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_SYMBOLS_PRIVATE_EXTERN = NO; INFOPLIST_FILE = Tests/CloudKitTests/App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.1; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = cloudtests.TestableApp; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1028,12 +1089,11 @@ DEVELOPMENT_TEAM = 7X2PJ6H6YM; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Tests/CloudKitTests/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.1; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = cloudtests.CloudKitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TestableApp.app/TestableApp"; }; @@ -1053,11 +1113,10 @@ DEVELOPMENT_TEAM = 7X2PJ6H6YM; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Tests/CloudKitTests/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.1; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = cloudtests.CloudKitTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 4.2; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TestableApp.app/TestableApp"; }; @@ -1072,15 +1131,13 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/CloudCoreTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.2; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks @loader_path/../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = uvasily.CloudCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_SWIFT3_OBJC_INFERENCE = Off; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -1092,14 +1149,12 @@ CODE_SIGN_IDENTITY = ""; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Tests/CloudCoreTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.2; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks @loader_path/../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = uvasily.CloudCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_SWIFT3_OBJC_INFERENCE = Off; - SWIFT_VERSION = 4.0; + SWIFT_VERSION = 5.0; }; name = Release; }; diff --git a/CloudCore.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/CloudCore.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/CloudCore.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore.xcscheme b/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore.xcscheme index ac2a4ade..90e5d898 100644 --- a/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore.xcscheme +++ b/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore.xcscheme @@ -1,6 +1,6 @@ + + + + @@ -41,23 +49,11 @@ - - - - - - - - - - - - + + - - + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/Podfile b/Example/Podfile index f614da88..66b879d9 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -1,10 +1,13 @@ # Uncomment the next line to define a global platform for your project -platform :ios, '10.0' +platform :ios, '13.0' target 'CloudCoreExample' do # Comment the next line if you're not using Swift and don't want to use dynamic frameworks use_frameworks! - + + pod 'CloudCore', :path => '../' + # Pods for CloudCoreExample pod 'Fakery', '~> 3.3.0' + pod 'Connectivity' end diff --git a/Example/Resources/Base.lproj/Main.storyboard b/Example/Resources/Base.lproj/Main.storyboard index eb869eb0..dac73e96 100644 --- a/Example/Resources/Base.lproj/Main.storyboard +++ b/Example/Resources/Base.lproj/Main.storyboard @@ -1,13 +1,9 @@ - - - - + + - - - + @@ -15,16 +11,16 @@ - + - + - + - + @@ -41,30 +37,39 @@ + + + + + + + + + @@ -79,6 +84,7 @@ + @@ -104,21 +110,21 @@ - + - + diff --git a/Example/Resources/Info.plist b/Example/Resources/Info.plist index 5cb06316..7a65846c 100644 --- a/Example/Resources/Info.plist +++ b/Example/Resources/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1 + $(MARKETING_VERSION) CFBundleVersion 1 LSRequiresIPhoneOS @@ -48,5 +48,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + CKSharingSupported + diff --git a/Example/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents b/Example/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents deleted file mode 100644 index 4bec3c35..00000000 --- a/Example/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Example/Sources/AppDelegate.swift b/Example/Sources/AppDelegate.swift index 7251cea8..26460f9f 100644 --- a/Example/Sources/AppDelegate.swift +++ b/Example/Sources/AppDelegate.swift @@ -8,7 +8,9 @@ import UIKit import CoreData +import CloudKit import CloudCore +import Connectivity let persistentContainer = (UIApplication.shared.delegate as! AppDelegate).persistentContainer @@ -16,8 +18,10 @@ let persistentContainer = (UIApplication.shared.delegate as! AppDelegate).persis class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { let delegateHandler = CloudCoreDelegateHandler() - - func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool { + + var connectivity: Connectivity? + + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { // Register for push notifications about changes application.registerForRemoteNotifications() @@ -25,32 +29,51 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDele CloudCore.delegate = delegateHandler CloudCore.enable(persistentContainer: persistentContainer) + let connectivityChanged: (Connectivity) -> Void = { connectivity in + let online : [ConnectivityStatus] = [.connected, .connectedViaCellular, .connectedViaWiFi] + CloudCore.isOnline = online.contains(connectivity.status) + } + + connectivity = Connectivity(shouldUseHTTPS: false) + connectivity?.whenConnected = connectivityChanged + connectivity?.whenDisconnected = connectivityChanged + connectivity?.startNotifier() + return true } - + // Notification from CloudKit about changes in remote database func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { // Check if it CloudKit's and CloudCore notification if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { // Fetch changed data from iCloud - CloudCore.fetchAndSave(using: userInfo, to: persistentContainer, error: { + CloudCore.pull(using: userInfo, to: persistentContainer, error: { print("fetchAndSave from didReceiveRemoteNotification error: \($0)") }, completion: { (fetchResult) in completionHandler(fetchResult.uiBackgroundFetchResult) }) } } - - func applicationWillTerminate(_ application: UIApplication) { - // Save tokens on exit used to differential sync - CloudCore.tokens.saveToUserDefaults() - } - + + // User accepted a sharing link, pull the complete record + func application(_ application: UIApplication, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) { + let acceptShareOperation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata]) + acceptShareOperation.qualityOfService = .userInteractive + acceptShareOperation.perShareCompletionBlock = { meta, share, error in + CloudCore.pull(rootRecordID: meta.rootRecordID, container: self.persistentContainer, error: nil) { } + } + acceptShareOperation.acceptSharesCompletionBlock = { error in + // N/A + } + CKContainer(identifier: cloudKitShareMetadata.containerIdentifier).add(acceptShareOperation) + } + // MARK: - Default Apple initialization, you can skip that var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } @@ -64,6 +87,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDele error conditions that could cause the creation of the store to fail. */ let container = NSPersistentContainer(name: "Model") + + if #available(iOS 11.0, *) { + let storeDescription = container.persistentStoreDescriptions.first + storeDescription?.setOption(true as NSNumber, forKey:NSPersistentHistoryTrackingKey) + } + container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { // Replace this implementation with code to handle the error appropriately. @@ -81,6 +110,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDele } }) container.viewContext.automaticallyMergesChangesFromParent = true + return container }() diff --git a/Example/Sources/Class/FRCTableViewDataSource.swift b/Example/Sources/Class/FRCTableViewDataSource.swift index 3abfcd47..762994fd 100644 --- a/Example/Sources/Class/FRCTableViewDataSource.swift +++ b/Example/Sources/Class/FRCTableViewDataSource.swift @@ -4,7 +4,7 @@ import UIKit import CoreData -protocol FRCTableViewDelegate: class { +protocol FRCTableViewDelegate: AnyObject { func frcTableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell } @@ -59,44 +59,11 @@ class FRCTableViewDataSource: NSObject // MARK: - NSFetchedResultsControllerDelegate - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - tableView?.beginUpdates() - } - - func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { - let sectionIndexSet = IndexSet(integer: sectionIndex) - - switch type { - case .insert: tableView?.insertSections(sectionIndexSet, with: .automatic) - case .delete: tableView?.deleteSections(sectionIndexSet, with: .automatic) - case .update: tableView?.reloadSections(sectionIndexSet, with: .automatic) - case .move: break - } - } - - func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { - switch type { - case .insert: - guard let newIndexPath = newIndexPath else { break } - tableView?.insertRows(at: [newIndexPath], with: .automatic) - case .delete: - guard let indexPath = indexPath else { break } - tableView?.deleteRows(at: [indexPath], with: .automatic) - case .update: - guard let indexPath = indexPath else { break } - tableView?.reloadRows(at: [indexPath], with: .automatic) - case .move: - guard let indexPath = indexPath, let newIndexPath = newIndexPath else { return } - tableView?.moveRow(at: indexPath, to: newIndexPath) - } - } - - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - tableView?.endUpdates() - } - - func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {} + // there are better ways to handle this + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + tableView?.reloadData() + } + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return nil } } - diff --git a/Example/Sources/Class/ModelFactory.swift b/Example/Sources/Class/ModelFactory.swift index 66a29dfd..388ba886 100644 --- a/Example/Sources/Class/ModelFactory.swift +++ b/Example/Sources/Class/ModelFactory.swift @@ -37,24 +37,37 @@ class ModelFactory { org.name = faker.company.name() org.bs = faker.company.bs() org.founded = Date(timeIntervalSince1970: faker.number.randomDouble(min: 1292250324, max: 1513175137)) - + + org.secretString = "This is a secret" + org.secretInteger = 42 + org.secretDouble = 3.14 + org.secretBoolean = true + return org } static func insertEmployee(context: NSManagedObjectContext) -> Employee { - let user = Employee(context: context) - user.department = faker.commerce.department() - user.name = faker.name.name() - user.workingSince = Date(timeIntervalSince1970: faker.number.randomDouble(min: 661109847, max: 1513186653)) - user.photoData = randomAvatar() - - return user + let employee = Employee(context: context) + employee.department = faker.commerce.department() + employee.name = faker.name.name() + employee.workingSince = Date(timeIntervalSince1970: faker.number.randomDouble(min: 661109847, max: 1513186653)) + + let datafile = Datafile(context: context) + datafile.suffix = ".png" + datafile.cacheState = .local + datafile.remoteStatus = .pending + datafile.employee = employee + + let photoData = randomAvatar() + try? photoData?.write(to: datafile.url) + + return employee } private static func randomAvatar() -> Data? { let randomNumber = String(faker.number.randomInt(min: 1, max: 9)) let image = UIImage(named: "avatar_" + randomNumber)! - return UIImagePNGRepresentation(image) + return image.pngData() } static func newCompanyName() -> String { diff --git a/Example/Sources/Model/CoreDataContextObserver.swift b/Example/Sources/Model/CoreDataContextObserver.swift new file mode 100644 index 00000000..66580c0d --- /dev/null +++ b/Example/Sources/Model/CoreDataContextObserver.swift @@ -0,0 +1,133 @@ +// +// CoreDataContextObserver.swift +// +// Created by Michal Zaborowski on 10.05.2016. +// Copyright © 2016 Inspace Labs Sp z o. o. Spółka Komandytowa. All rights reserved. +// +import Foundation +import CoreData + +public struct CoreDataContextObserverState: OptionSet { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public static let Inserted = CoreDataContextObserverState(rawValue: 1 << 0) + public static let Updated = CoreDataContextObserverState(rawValue: 1 << 1) + public static let Deleted = CoreDataContextObserverState(rawValue: 1 << 2) + public static let Refreshed = CoreDataContextObserverState(rawValue: 1 << 3) + + public static let All: CoreDataContextObserverState = [Inserted, Updated, Deleted, Refreshed] +} + +public typealias CoreDataContextObserverCompletionBlock = (NSManagedObject,CoreDataContextObserverState) -> () +public typealias CoreDataContextObserverContextChangeBlock = (_ notification: NSNotification, _ changedObjects: [CoreDataObserverObjectChange]) -> () + +public enum CoreDataObserverObjectChange { + case Updated(NSManagedObject) + case Refreshed(NSManagedObject) + case Inserted(NSManagedObject) + case Deleted(NSManagedObject) + + public func managedObject() -> NSManagedObject { + switch self { + case let .Updated(value): return value + case let .Inserted(value): return value + case let .Refreshed(value): return value + case let .Deleted(value): return value + } + } +} + +public struct CoreDataObserverAction { + var state: CoreDataContextObserverState + var completionBlock: CoreDataContextObserverCompletionBlock +} + +public class CoreDataContextObserver { + public var enabled: Bool = true + public var contextChangeBlock: CoreDataContextObserverContextChangeBlock? + + private var notificationObserver: NSObjectProtocol? + private(set) var context: NSManagedObjectContext + private(set) var actionsForManagedObjectID: Dictionary = [:] + private(set) weak var persistentStoreCoordinator: NSPersistentStoreCoordinator? + + deinit { + unobserveAllObjects() + if let notificationObserver = notificationObserver { + NotificationCenter.default.removeObserver(notificationObserver) + } + } + + public init(context: NSManagedObjectContext) { + self.context = context + self.persistentStoreCoordinator = context.persistentStoreCoordinator + + notificationObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.NSManagedObjectContextObjectsDidChange, object: context, queue: nil) { [weak self] notification in + self?.handleContextObjectDidChangeNotification(notification: notification as NSNotification) + } + } + + private func handleContextObjectDidChangeNotification(notification: NSNotification) { + guard let incomingContext = notification.object as? NSManagedObjectContext, + let persistentStoreCoordinator = persistentStoreCoordinator, + let incomingPersistentStoreCoordinator = incomingContext.persistentStoreCoordinator, enabled && persistentStoreCoordinator == incomingPersistentStoreCoordinator else { + return + } + + let insertedObjectsSet = notification.userInfo?[NSInsertedObjectsKey] as? Set ?? Set() + let updatedObjectsSet = notification.userInfo?[NSUpdatedObjectsKey] as? Set ?? Set() + let deletedObjectsSet = notification.userInfo?[NSDeletedObjectsKey] as? Set ?? Set() + let refreshedObjectsSet = notification.userInfo?[NSRefreshedObjectsKey] as? Set ?? Set() + + var combinedObjectChanges = insertedObjectsSet.map({ CoreDataObserverObjectChange.Inserted($0) }) + combinedObjectChanges += updatedObjectsSet.map({ CoreDataObserverObjectChange.Updated($0) }) + combinedObjectChanges += deletedObjectsSet.map({ CoreDataObserverObjectChange.Deleted($0) }) + combinedObjectChanges += refreshedObjectsSet.map({ CoreDataObserverObjectChange.Refreshed($0) }) + + contextChangeBlock?(notification, combinedObjectChanges) + + let combinedSet = insertedObjectsSet.union(updatedObjectsSet).union(deletedObjectsSet).union(refreshedObjectsSet) + let allObjectIDs = Array(actionsForManagedObjectID.keys) + let filteredObjects = combinedSet.filter({ allObjectIDs.contains($0.objectID) }) + + for object in filteredObjects { + guard let actionsForObject = actionsForManagedObjectID[object.objectID] else { continue } + + for action in actionsForObject { + if action.state.contains(.Inserted) && insertedObjectsSet.contains(object) { + action.completionBlock(object,.Inserted) + } else if action.state.contains(.Updated) && updatedObjectsSet.contains(object) { + action.completionBlock(object,.Updated) + } else if action.state.contains(.Deleted) && deletedObjectsSet.contains(object) { + action.completionBlock(object,.Deleted) + } else if action.state.contains(.Refreshed) && refreshedObjectsSet.contains(object) { + action.completionBlock(object,.Refreshed) + } + } + } + } + + public func observeObject(object: NSManagedObject, state: CoreDataContextObserverState = .All, completionBlock: @escaping CoreDataContextObserverCompletionBlock) { + let action = CoreDataObserverAction(state: state, completionBlock: completionBlock) + if var actionArray = actionsForManagedObjectID[object.objectID] { + actionArray.append(action) + actionsForManagedObjectID[object.objectID] = actionArray + } else { + actionsForManagedObjectID[object.objectID] = [action] + } + + } + + public func unobserveObject(object: NSManagedObject, forState state: CoreDataContextObserverState = .All) { + if state == .All { + actionsForManagedObjectID[object.objectID] = nil + } else if let actionsForObject = actionsForManagedObjectID[object.objectID] { + actionsForManagedObjectID[object.objectID] = actionsForObject.filter { !$0.state.contains(state) } + } + } + + public func unobserveAllObjects() { + actionsForManagedObjectID.removeAll() + } +} diff --git a/Example/Sources/Model/Datafile+CloudCoreCacheable.swift b/Example/Sources/Model/Datafile+CloudCoreCacheable.swift new file mode 100644 index 00000000..6525cb4e --- /dev/null +++ b/Example/Sources/Model/Datafile+CloudCoreCacheable.swift @@ -0,0 +1,25 @@ +// +// Datafile+CloudCoreCacheable.swift +// CloudCoreExample +// +// Created by deeje cooley on 4/18/22. +// Copyright © 2022 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit +import CloudCore + +extension Datafile: CloudCoreCacheable { + + override public func awakeFromInsert() { + super.awakeFromInsert() + + recordName = UUID().uuidString // want this precomputed so that url is functional + } + + override public func prepareForDeletion() { + removeLocal() + } + +} diff --git a/Example/Sources/Model/Datafile+CoreDataClass.swift b/Example/Sources/Model/Datafile+CoreDataClass.swift new file mode 100644 index 00000000..ac77bb6b --- /dev/null +++ b/Example/Sources/Model/Datafile+CoreDataClass.swift @@ -0,0 +1,14 @@ +// +// Datafile+CoreDataClass.swift +// CloudCoreExample +// +// Created by deeje cooley on 4/18/22. +// Copyright © 2022 Vasily Ulianov. All rights reserved. +// + +import CoreData + +@objc(Datafile) +public class Datafile: NSManagedObject { + +} diff --git a/Example/Sources/Model/Model.xcdatamodeld/Model.xcdatamodel/contents b/Example/Sources/Model/Model.xcdatamodeld/Model.xcdatamodel/contents new file mode 100644 index 00000000..de799290 --- /dev/null +++ b/Example/Sources/Model/Model.xcdatamodeld/Model.xcdatamodel/contents @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Example/Sources/Model/Organization+CloudCoreSharing.swift b/Example/Sources/Model/Organization+CloudCoreSharing.swift new file mode 100644 index 00000000..a404ef9e --- /dev/null +++ b/Example/Sources/Model/Organization+CloudCoreSharing.swift @@ -0,0 +1,46 @@ +// +// Person+CloudKit.swift +// WTSDA +// +// Created by deeje cooley on 12/28/18. +// Copyright © 2018 deeje LLC. All rights reserved. +// + +import CoreData +import CloudKit +import CloudCore + +extension Organization: CloudCoreSharing { + + public var sharingTitle: String? { + return name + } + + public var sharingType: String? { + return "com.deeje.sample.CloudCore.organization" + } + + public var sharingImage: Data? { + return nil + } + + /* + public var recordName: String? { + return uuid + } + + public var ownerName: String? { + return ownerUUID + } + + public var shareRecordData: Data? { + get { + return shareData + } + set { + shareData = newValue + } + } + */ + +} diff --git a/Example/Sources/Model/Organization+CoreDataClass.swift b/Example/Sources/Model/Organization+CoreDataClass.swift new file mode 100644 index 00000000..3706bfba --- /dev/null +++ b/Example/Sources/Model/Organization+CoreDataClass.swift @@ -0,0 +1,15 @@ +// +// Person+CoreDataClass.swift +// +// +// Created by deeje cooley on 10/6/18. +// +// This file was automatically generated and then moved into the project. +// + +import CoreData + +@objc(Organization) +public class Organization: NSManagedObject { + +} diff --git a/Example/Sources/View Controller/DetailViewController.swift b/Example/Sources/View Controller/DetailViewController.swift index d9614003..fb819ff3 100644 --- a/Example/Sources/View Controller/DetailViewController.swift +++ b/Example/Sources/View Controller/DetailViewController.swift @@ -8,6 +8,7 @@ import UIKit import CoreData +import CloudCore class DetailViewController: UITableViewController { @@ -16,6 +17,15 @@ class DetailViewController: UITableViewController { private var tableDataSource: DetailTableDataSource! + private var sharingController: CloudCoreSharingController! + + private var datafilesObserver: CoreDataContextObserver! + private var updateCellQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() + override func viewDidLoad() { super.viewDidLoad() @@ -26,39 +36,71 @@ class DetailViewController: UITableViewController { tableDataSource = DetailTableDataSource(fetchRequest: fetchRequest, context: context, sectionNameKeyPath: nil, delegate: self, tableView: tableView) tableView.dataSource = tableDataSource + tableView.delegate = self try! tableDataSource.performFetch() - - navigationItem.rightBarButtonItem = editButtonItem - } - - override func setEditing(_ editing: Bool, animated: Bool) { - super.setEditing(editing, animated: animated) - - if editing { - let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(navAddButtonDidTap(_:))) - navigationItem.setLeftBarButton(addButton, animated: animated) - - let renameButton = UIBarButtonItem(title: "Rename", style: .plain, target: self, action: #selector(navRenameButtonDidTap(_:))) - navigationItem.setRightBarButtonItems([editButtonItem, renameButton], animated: animated) - } else { - navigationItem.setLeftBarButton(nil, animated: animated) - navigationItem.setRightBarButtonItems([editButtonItem], animated: animated) - try! context.save() - } + + guard let organization = try? self.context.existingObject(with: self.organizationID) as? CloudCoreSharing else { return } + + var buttons: [UIBarButtonItem] = [] + if organization.isOwnedByCurrentUser { + let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add(_:))) + buttons.append(addButton) + + let renameButton = UIBarButtonItem(title: "Rename", style: .plain, target: self, action: #selector(rename(_:))) + buttons.append(renameButton) + } + let shareButton = UIBarButtonItem(title: "Share", style: .plain, target: self, action: #selector((share(_:)))) + buttons.append(shareButton) + + navigationItem.setRightBarButtonItems(buttons, animated: false) + + datafilesObserver = CoreDataContextObserver(context: context) } - - @objc private func navAddButtonDidTap(_ sender: UIBarButtonItem) { - let employee = ModelFactory.insertEmployee(context: context) - let organization = context.object(with: organizationID) as! Organization - employee.organization = organization + + @objc private func add(_ sender: UIBarButtonItem) { + persistentContainer.performBackgroundPushTask { (moc) in + let employee = ModelFactory.insertEmployee(context: moc) + let organization = try? moc.existingObject(with: self.organizationID) as? Organization + employee.organization = organization + + try? moc.save() + } } - @objc private func navRenameButtonDidTap(_ sender: UIBarButtonItem) { - let organization = context.object(with: organizationID) as! Organization - organization.name = ModelFactory.newCompanyName() - self.title = organization.name + @objc private func rename(_ sender: UIBarButtonItem) { + let newTitle = ModelFactory.newCompanyName() + persistentContainer.performBackgroundPushTask { (moc) in + let organization = try? moc.existingObject(with: self.organizationID) as? Organization + organization?.name = newTitle + + try? moc.save() + } + self.title = newTitle } - + + @objc private func share(_ sender: UIBarButtonItem) { + iCloudAvailable { available in + guard available else { return } + + guard let organization = try? self.context.existingObject(with: self.organizationID) as? CloudCoreSharing else { return } + + if self.sharingController == nil { + self.sharingController = CloudCoreSharingController(persistentContainer: persistentContainer, + object: organization) + } + self.sharingController.configureSharingController(permissions: [.allowReadOnly, .allowPrivate, .allowPublic]) { csc in + if let csc = csc { + csc.popoverPresentationController?.barButtonItem = sender + self.present(csc, animated:true, completion:nil) + } + } + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + } + } extension DetailViewController: FRCTableViewDelegate { @@ -68,12 +110,47 @@ extension DetailViewController: FRCTableViewDelegate { let employee = tableDataSource.object(at: indexPath) cell.nameLabel.text = employee.name - - if let imageData = employee.photoData, let image = UIImage(data: imageData) { - cell.photoImageView.image = image - } else { - cell.photoImageView.image = nil - } + + cell.progressView.isHidden = true + cell.progressView.progress = 0 + + if let datafile = employee.datafiles?.allObjects.first as? Datafile { + if datafile.localAvailable { + if let data = try? Data(contentsOf: datafile.url) + { + cell.photoImageView.image = UIImage(data: data) + } + } else if datafile.readyToDownload { + datafilesObserver.observeObject(object: datafile) { datafile, state in + guard let cacheable = datafile as? CloudCoreCacheable else { return } + + cell.progressView.isHidden = cacheable.progress == 0 + cell.progressView.progress = Float(cacheable.progress) + + if cacheable.localAvailable { + if let data = try? Data(contentsOf: cacheable.url) + { + cell.photoImageView.image = UIImage(data: data) + } + cell.progressView.isHidden = true + cell.progressView.progress = 0 + + self.datafilesObserver.unobserveObject(object: datafile) + } + } + + persistentContainer.performBackgroundTask { moc in + guard let cacheable = try? moc.existingObject(with: datafile.objectID) as? CloudCoreCacheable else { return } + + cacheable.cacheState = .download + + try? moc.save() + } + } else { + cell.progressView.isHidden = datafile.progress == 0 + cell.progressView.progress = Float(datafile.progress) + } + } var departmentText = employee.department ?? "No" departmentText += " department" @@ -93,15 +170,33 @@ extension DetailViewController: FRCTableViewDelegate { } +extension DetailViewController { + + @available(iOS 11.0, *) + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteTitle = NSLocalizedString("Delete", comment: "Delete action") + let deleteAction = UIContextualAction(style: .destructive, title: deleteTitle, + handler: { [weak self] action, view, completionHandler in + + let anObject = self?.tableDataSource.object(at: indexPath) + let objectID = anObject?.objectID + + persistentContainer.performBackgroundPushTask { (moc) in + if let objectToDelete = try? moc.existingObject(with: objectID!) { + moc.delete(objectToDelete) + try? moc.save() + } + } + + completionHandler(true) + }) + + let configuration = UISwipeActionsConfiguration(actions: [deleteAction]) + return configuration + } + +} + fileprivate class DetailTableDataSource: FRCTableViewDataSource { - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { - let context = frc.managedObjectContext - - switch editingStyle { - case .delete: context.delete(object(at: indexPath)) - default: return - } - } - + } diff --git a/Example/Sources/View Controller/MasterViewController.swift b/Example/Sources/View Controller/MasterViewController.swift index e41fecb8..e5269eb9 100644 --- a/Example/Sources/View Controller/MasterViewController.swift +++ b/Example/Sources/View Controller/MasterViewController.swift @@ -21,7 +21,7 @@ class MasterViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - + let fetchRequest: NSFetchRequest = Organization.fetchRequest() fetchRequest.sortDescriptors = [NSSortDescriptor(key: "sort", ascending: true)] tableDataSource = MasterTableViewDataSource(fetchRequest: fetchRequest, context: context, sectionNameKeyPath: nil, delegate: self, tableView: tableView) @@ -29,31 +29,22 @@ class MasterViewController: UITableViewController { try! tableDataSource.performFetch() self.clearsSelectionOnViewWillAppear = true - - self.navigationItem.rightBarButtonItem = editButtonItem - } - - override func setEditing(_ editing: Bool, animated: Bool) { - super.setEditing(editing, animated: animated) - - // Save on editing end - if !editing { - try! context.save() - } } - + @IBAction func addButtonClicked(_ sender: UIBarButtonItem) { - ModelFactory.insertOrganizationWithEmployees(context: context) - try! context.save() + persistentContainer.performBackgroundPushTask { (moc) in + ModelFactory.insertOrganizationWithEmployees(context: moc) + try! moc.save() + } } @IBAction func refreshValueChanged(_ sender: UIRefreshControl) { - CloudCore.fetchAndSave(to: persistentContainer, error: { (error) in + CloudCore.pull(to: persistentContainer, error: { (error) in print("⚠️ FetchAndSave error: \(error)") DispatchQueue.main.async { sender.endRefreshing() } - }) { + }) { _ in DispatchQueue.main.async { sender.endRefreshing() } @@ -70,7 +61,7 @@ class MasterViewController: UITableViewController { } } - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCellEditingStyle { + override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { return .delete } @@ -92,15 +83,84 @@ extension MasterViewController: FRCTableViewDelegate { } +extension MasterViewController { + + @available(iOS 11.0, *) + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + var actions: [UIContextualAction] = [] + + let anObject = tableDataSource.object(at: indexPath) as Organization + if anObject.isOwnedByCurrentUser { + let deleteTitle = NSLocalizedString("Delete", comment: "Delete action") + let deleteAction = UIContextualAction(style: .destructive, title: deleteTitle) { [weak self] action, view, completionHandler in + self?.confirmDelete(objectID: anObject.objectID, completion: completionHandler) + } + actions.append(deleteAction) + } else { + let RemoveTitle = NSLocalizedString("Remove", comment: "Remove action") + let removeAction = UIContextualAction(style: .destructive, title: RemoveTitle) { [weak self] action, view, completionHandler in + self?.confirmRemove(objectID: anObject.objectID, completion: completionHandler) + } + actions.append(removeAction) + } + + let configuration = UISwipeActionsConfiguration(actions: actions) + return configuration + } + + func confirmDelete(objectID: NSManagedObjectID, completion: @escaping ((Bool) -> Void) ) { + let alert = UIAlertController(title: "Are you sure you want to delete?", message: "This will permanently delete this object from all devices", preferredStyle: .alert) + let confirm = UIAlertAction(title: "Delete", style: .destructive) { _ in + persistentContainer.performBackgroundPushTask { moc in + if let personEntity = try? moc.existingObject(with: objectID) { + moc.delete(personEntity) + try? moc.save() + } + DispatchQueue.main.async { + completion(true) + } + } + } + alert.addAction(confirm) + let cancel = UIAlertAction(title: "Cancel", style: .cancel) { _ in + completion(false) + } + alert.addAction(cancel) + self.present(alert, animated: true) + } + + func confirmRemove(objectID: NSManagedObjectID, completion: @escaping ((Bool) -> Void) ) { + let alert = UIAlertController(title: "Are you sure you want to remove?", message: "This will permanently remove this object from all your devices", preferredStyle: .alert) + let confirm = UIAlertAction(title: "Remove", style: .destructive) { _ in + guard let object = (try? self.context.existingObject(with: objectID)) as? Organization else { return } + + object.stopSharing(in: persistentContainer) { didStop in + if didStop { + persistentContainer.performBackgroundTask { moc in + if let deleteObject = try? moc.existingObject(with: objectID) { + moc.delete(deleteObject) + try? moc.save() + } + DispatchQueue.main.async { + completion(true) + } + } + } else { + completion(false) + } + } + } + alert.addAction(confirm) + let cancel = UIAlertAction(title: "Cancel", style: .cancel) { _ in + completion(false) + } + alert.addAction(cancel) + self.present(alert, animated: true) + } + +} + fileprivate class MasterTableViewDataSource: FRCTableViewDataSource { - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { - let context = frc.managedObjectContext - switch editingStyle { - case .delete: context.delete(object(at: indexPath)) - default: return - } - } - } + diff --git a/Example/Sources/View/EmployeeTableViewCell.swift b/Example/Sources/View/EmployeeTableViewCell.swift index 71021935..6d7a2b5b 100644 --- a/Example/Sources/View/EmployeeTableViewCell.swift +++ b/Example/Sources/View/EmployeeTableViewCell.swift @@ -14,5 +14,14 @@ class EmployeeTableViewCell: UITableViewCell { @IBOutlet weak var nameLabel: UILabel! @IBOutlet weak var departmentLabel: UILabel! @IBOutlet weak var sinceLabel: UILabel! + @IBOutlet weak var progressView: UIProgressView! + override func prepareForReuse() { + super.prepareForReuse() + + photoImageView.image = nil + progressView.progress = 0 + progressView.isHidden = true + } + } diff --git a/LICENSE.md b/LICENSE.md index 2eee9f31..9b64f6b5 100755 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Vasily Ulianov +Copyright (c) 2021 deeje cooley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6e039588..12e5f62d 100755 --- a/README.md +++ b/README.md @@ -1,29 +1,66 @@ # CloudCore -[![Documentation](https://sorix.github.io/CloudCore/badge.svg)](https://sorix.github.io/CloudCore/) -[![Version](https://img.shields.io/cocoapods/v/CloudCore.svg?style=flat)](https://cocoapods.org/pods/CloudCore) ![Platform](https://img.shields.io/cocoapods/p/CloudCore.svg?style=flat) -![Status](https://img.shields.io/badge/status-beta-orange.svg) -![Swift](https://img.shields.io/badge/swift-4-orange.svg) +![Status](https://img.shields.io/badge/status-production-green.svg) +![Swift](https://img.shields.io/badge/swift-5.0-orange.svg) -**CloudCore** is a framework that manages syncing between iCloud (CloudKit) and Core Data written on native Swift. It maybe used are CloudKit caching. +**CloudCore** is an advanced sync engine for CloudKit and Core Data. #### Features -* Sync manually or on **push notifications**. -* **Differential sync**, only changed object and values are uploaded and downloaded. CloudCore even differs changed and not changed values inside objects. -* Respects of Core Data options (cascade deletions, external storage). -* Knows and manages with CloudKit errors like `userDeletedZone`, `zoneNotFound`, `changeTokenExpired`, `isMore`. -* Covered with Unit and CloudKit online **tests**. -* All public methods are **[100% documented](https://sorix.github.io/CloudCore/)**. -* Currently only **private database** is supported. +* Leveraging **NSPersistentHistory**, local changes are pushed to CloudKit when online. Never lose a change again. +* Pull manually or on CloudKit **remote notifications**. +* **Differential sync**, only changed object and values are uploaded and downloaded. +* Core Data relationships are preserved +* **private database** and **shared database** push and pull is supported. +* **public database** push is supported +* Parent-Child relationships can be defined for CloudKit Sharing +* Respects Core Data options (cascade deletions, external storage). +* Support for 'Allows Cloud Encryption' for attributes in Core Data with automatic encoding to and from encryptedValues[] in CloudKit. +* Knows and manages CloudKit errors like `userDeletedZone`, `zoneNotFound`, `changeTokenExpired`, `isMore`. +* Available on iOS and iPadOS (watchOS and tvOS haven't been tested) +* Sharing can be extended to your NSManagedObject classes, and native SharingUI is implemented +* Maskable Attributes allows you to control which attributes are ignored during upload and/or download. +* Cacheable Assets are uploaded automatically and downloaded on-demand, using long-lived operations separate from sync operations. + +#### CloudCore vs NSPersistentCloudKitContainer? + +NSPersistentCloudKitContainer provides native support for Core Data <-> CloudKit synchronization. Here are some thoughts on the differences between these two approaches, as of May 2022. + +###### NSPersistentCloudKitContainer +* Simple to enable +* Support for Private, Shared, and Public databases +* Synchronizes All Records +* No CloudKit Metadata (e.g. recordName, systemFields, owner) +* Record-level Synchronization (entire objects are pushed) +* Offline Synchronization is opaque, but doesn't appear to require NSPersistentHistoryTracking +* All Core Data names are preceeded with "CD_" in CloudKit +* Core Data Relationships are mapped thru CDMR records in CloudKit +* Sharing is supported via zones +* No(?) long-lived operations support for large file upload/download + +###### CloudCore +* Support requires specific configuration in the Core Data Model +* Support for Private, Shared, and Public databases +* Selective Synchronization (e.g. can delete local objects without deleting remote records) +* Explicit CloudKit Metadata +* Field-level Synchronization (only changed attributes are pushed) +* Offline Synchronziation via NSPersistentHistoryTracking +* Core Data names are mapped exactly in CloudKit +* Core Data Relationships are mapped to CloudKit CKReferences +* Maskable Attributes provides fine-grain control over local-only data and manually managed remote data +* Sharing is supported via root records +* Supports upload/download of large data files via long-lived operations, with proper schema configuration + +Apple very clearly states that NSPersistentCloudKitContainer is a foundation for future support of more advanced features. I'm still waiting to learn which first-party apps use it. #YMMV ## How it works? -CloudCore is built using "black box" architecture, so it works invisibly for your application, you just need to add several lines to `AppDelegate` to enable it. Synchronization and error resolving is managed automatically. +CloudCore is built using a "black box" architecture, so it works fairly invisibly for your application. You just need to add several lines to your `AppDelegate` to enable it, as well as identify various aspects of your Core Data Model schema. Synchronization and error resolving is managed automatically. 1. CloudCore stores *change tokens* from CloudKit, so only changed data is downloaded. -2. When CloudCore is enabled (`CloudCore.enable`) it fetches changed data from CloudKit and subscribes to CloudKit push notifications about new changes. -3. When `CloudCore.fetchAndSave` is called manually or by push notification, CloudCore fetches and saves changed data to Core Data. -4. When data is written to persistent container (parent context is saved) CloudCore founds locally changed data and uploads it to CloudKit. +2. When CloudCore is enabled (`CloudCore.enable`) it pulls changed data from CloudKit and subscribes to CloudKit push notifications about new changes. +3. When `CloudCore.pull` is called manually or by push notification, CloudCore pulls and saves changed data to Core Data. +4. When data is written to your persistent container (parent context is saved) CloudCore finds locally changed data and pushes to CloudKit. +5. By leveraging NSPersistentHistory, changes can be queued when offline and pushed when online. ## Installation @@ -32,25 +69,31 @@ CloudCore is built using "black box" architecture, so it works invisibly for you it, simply add the following line to your Podfile: ```ruby -pod 'CloudCore', '~> 2.0' +pod 'CloudCore' ``` ## How to help? -Current version of framework hasn't been deeply tested and may contain errors. If you can test framework, I will be very glad. If you found an error, please post [an issue](https://github.com/Sorix/CloudCore/issues). - -## Documentation -All public methods are documented using [XCode Markup](https://developer.apple.com/library/content/documentation/Xcode/Reference/xcode_markup_formatting_ref/) and available inside XCode. -HTML-generated version of that documentation is [**available here**](https://sorix.github.io/CloudCore/). +What would you like to see improved? ## Quick start 1. Enable CloudKit capability for you application: + ![CloudKit capability](https://cloud.githubusercontent.com/assets/5610904/25092841/28305bc0-2398-11e7-9fbf-f94c619c264f.png) -2. Add 2 service attributes to each entity in CoreData model you want to sync: - * `recordData` attribute with `Binary` type - * `recordID` attribute with `String` type +2. For each entity type you want to sync, add this key: value pair to the UserInfo record of the entity: +* `CloudCoreScopes`: `private` + +3. Also add 4 attributes to each entity: + * `privateRecordData` attribute with `Binary` type + * `publicRecordData` attribute with `Binary` type + * `recordName` attribute with `String` type + * `ownerName` attribute with `String` type -3. Make changes in your **AppDelegate.swift** file: +4. And enable 'Preserve After Deletion' for the following attributes + * `privateRecordData` + * `publicRecordData` + +5. Make changes in your **AppDelegate.swift** file: ```swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { @@ -68,46 +111,186 @@ func application(_ application: UIApplication, didReceiveRemoteNotification user // Check if it CloudKit's and CloudCore notification if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { // Fetch changed data from iCloud - CloudCore.fetchAndSave(using: userInfo, to: persistentContainer, error: nil, completion: { (fetchResult) in + CloudCore.pull(using: userInfo, to: persistentContainer, error: nil, completion: { (fetchResult) in completionHandler(fetchResult.uiBackgroundFetchResult) }) } } +``` + +6. **Enable NSPersistentHistoryTracking** when you initialize your Core Data stack + +```swift +lazy var persistentContainer: NSPersistentContainer = { + let container = NSPersistentContainer(name: "YourApp") + + let storeDescription = container.persistentStoreDescriptions.first + storeDescription?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) -func applicationWillTerminate(_ application: UIApplication) { - // Save tokens on exit used to differential sync - CloudCore.tokens.saveToUserDefaults() + container.loadPersistentStores { storeDescription, error in + if let error = error as NSError? { + // Replace this implementation with code to handle the error appropriately. + } + } + return container +}() +``` + +7. To identify changes from your app that should be pushed, **save** from the convenience function performBackgroundPushTask + +```swift +persistentContainer.performBackgroundPushTask { moc in + // make changes to objects, properties, and relationships you want pushed via CloudCore + try? context.save() } ``` -4. Make first run of your application in a development environment, fill an example data in Core Data and wait until sync completes. CloudCore create needed CloudKit schemes automatically. +8. Make first run of your application in a development environment, fill an example data in Core Data and wait until sync completes. CloudKit will create needed schemas automatically. ## Service attributes -CloudCore stores service CloudKit information in managed objects, you need to add that attributes to your Core Data model. If required attributes are not found in entity that entity won't be synced. +CloudCore stores CloudKit information inside your managed objects, so you need to add attributes to your Core Data model for that. If required attributes are not found in an entity, that entity won't be synced. Required attributes for each synced entity: -1. *Record Data* attribute with `Binary` type -2. *Record ID* attribute with `String` type +1. *Private Record Data* attribute with `Binary` type +2. *Public Record Data* attribute with `Binary` type +3. *Record Name* attribute with `String` type +4. *Owner Name* attribute with `String` type + +You may specify attributes' names in one of two 2 ways (you may combine that ways in different entities). -You may specify attributes' names in 2 ways (you may combine that ways in different entities). +### Default names +The most simple way is to name attributes with default names because you don't need to map them in UserInfo. -### User Info -First off CloudCore try to search attributes by looking up User Info at your model, you may specify User Info key `CloudCoreType` for attribute to mark one as service one. Values are: -* *Record Data* value is `recordData`. -* *Record ID* value is `recordID`. +### Mapping via UserInfo +You can map your own attributes to the required service attributes. For each attribute you want to map, add an item to the attribute's UserInfo, using the key `CloudCoreType` and following values: +* *Record Name* value is `recordName`. +* *Owner Name* value is `ownerName`. ![Model editor User Info](https://cloud.githubusercontent.com/assets/5610904/24004400/52e0ff94-0a77-11e7-9dd9-e1e24a86add5.png) -### Default names -The most simple way is to name attributes with default names because you don't need to specify any User Info. +When your *entities have relationships*, CloudCore will look for the following key:value pair in the UserInfo of your entities: + +`CloudCoreParent`: name of the to-one relationship property in your entity ### 💡 Tips -* You can name attribute as you want, value of User Info is not changed (you can create attribute `myid` with User Info: `CloudCoreType: recordID`) -* I recommend to mark *Record ID* attribute as `Indexed`, that can speed up updates in big databases. -* *Record Data* attribute is used to store archived version of `CKRecord` with system fields only (like timestamps, tokens), so don't worry about size, no real data will be stored here. +* I recommend to set the *Record Name* attribute as `Indexed`, to speed up updates in big databases. +* *P… Record Data* attributes are used to store archived version of `CKRecord` with system fields only (like timestamps, tokens), so don't worry about size, no real data will be stored here. + +## Scope: Public and/or Private +You can designate which databases each entity will synchronized with. For each entity you want to synchronize, add an item to the entity's UserInfo, using the key `CloudCoreScope` and following values: +* `public` = pushed to public database +* `private` = synchronized with private (or shared) database +* 'public,private' = both + +### Why Both? +Maintaining two copies of a record means we get all the benefits of a private (and sharable) record, while also automatically maintaining a fully updated public copy. + +## Maskable Attributes +You can designate attributes in your managed objects to be masked during upload and/or download. For each attribute you want to mask, add an item to the attribute's UserInfo, using the key `CloudCoreMasks` and following values: +* `upload` = ignored during modify operations +* `download` = ignored during fetch operations +* `upload,download` = both + +## Cacheable Assets +By default, CloudCore will transform assets in your CloudKit records into binary data attributes in your Core Data objects. + +But when you're working with very large files, such as photos, audio, or video, this default mode isn't optimal. + +* Uploading large files can take a long time, and sync will fail if not completed timely. +* To optimize a user's device storage, you may want to downloading large files on-demand. + +Cacheable Assets addresses these requirements by leveraging Maskable Attributes to ignore asset fields during sync, and then enabling push and pull of asset fields using long-lived operations. + +In order to manage cache state, assets must be stored in their own special entity type in your existing schema, which comform to the CloudCoreCacheable protocol. This protocol defines a number of attributes required to manage cache state: + +```swift +public protocol CloudCoreCacheable: CloudCoreType { + // fully masked + var cacheStateRaw: String? { get set } + var operationID: String? { get set } + var uploadProgress: Double { get set } + var downloadProgress: Double { get set } + var lastErrorMessage: String? { get set } + // sync'ed + var remoteStatusRaw: String? { get set } + var suffix: String? { get set } +} +``` + +The heart of CloudCoreCacheable is implemented using the following properties: + +```swift +public extension CloudCoreCacheable { + + var cacheState: CacheState + var remoteStatus: RemoteStatus + var url: URL + +} +``` + +Once you've configured your Core Data schema to support cacheable assets, you can create and download them as needed. + +When you create a new cacheable managed object, you must store its data at the file URL before saving it. The default value of cacheState is "local" and the default value of remoteStatus is "pending". Once CloudCore pushes the new cacheable record, it sets the cacheState to "upload", which triggers a long-lived modify operation. On completion, the cacheable managed object will have its cacheState set to "cached" and its remoteStatus set to "available". + +When cacheable records are pulled from CloudKit, the asset field is ignored (because it is masked), and the cacheState will be "remote". When the remoteStatus is "available", you can trigger a long-lived fetch operation by setting the cacheState to "download" and saving the object. Once completed, the cacheable object will have its cacheState set to "cached", and the data will be locally available at the file URL. + +Note that cacheState represents a state machine. +``` +(**new**) => local -> (push) -> upload -> uploading -> cached +(pull) => remote -> **download** -> downloading -> cached +``` + +### Important +See the Example app for specific details. Note, specifically, that I **need to override awakeFromInsert and prepareForDeletion** for my cacheable managed object type Datafile. If anyone has ideas on how to push this critical implementation detail into CloudCore itself, let me know! + +## CloudKit Sharing +CloudCore has built-in support for CloudKit Sharing. There are several additional steps you must take to enable it in your application. + +1. Add the CKSharingSupported key, with value true, to your info.plist + +2. Implement the appropriate delegate(… userDidAcceptCloudKitShare), something like… + +```swift +func windowScene(_ windowScene: UIWindowScene, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) { + let acceptShareOperation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata]) + acceptShareOperation.qualityOfService = .userInitiated + acceptShareOperation.perShareCompletionBlock = { meta, share, error in + CloudCore.pull(rootRecordID: meta.rootRecordID, container: self.persistentContainer, error: nil) { } + } + acceptShareOperation.acceptSharesCompletionBlock = { error in + // N/A + } + CKContainer(identifier: cloudKitShareMetadata.containerIdentifier).add(acceptShareOperation) +} +``` + +OR + +```swift +func application(_ application: UIApplication, + userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) { + let acceptShareOperation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata]) + acceptShareOperation.qualityOfService = .userInitiated + acceptShareOperation.perShareCompletionBlock = { meta, share, error in + CloudCore.pull(rootRecordID: meta.rootRecordID, container: self.persistentContainer, error: nil) { } + } + acceptShareOperation.acceptSharesCompletionBlock = { error in + // N/A + } + CKContainer(identifier: cloudKitShareMetadata.containerIdentifier).add(acceptShareOperation) +} +``` + +Note that when a user accepts a share, the app does not receive a remote notification of changes from iCloud, and so it must specifically pull the shared record in. + +3. Use a CloudCoreSharingController to configure a UICloudSharingController for presentation + +4. When a user wants to delete an object, your app must distinguish between the owner and a sharer, and either delete the object or the share. ## Example application -You can find example application at [Example](/Example/) directory. +You can find example application at [Example](/Example/) directory, which has been updated to demonstrate sharing, maskable attributes, and cacheable assets. **How to run it:** 1. Set Bundle Identifier. @@ -116,11 +299,15 @@ You can find example application at [Example](/Example/) directory. **How to use it:** * **+** button adds new object to local storage (that will be automatically synced to Cloud) -* **refresh** button calls `fetchAndSave` to fetch data from Cloud. That is useful button for simulators because Simulator unable to receive push notifications +* **Share* button presents the CloudKit Sharing UI +* **refresh** button calls `pull` to fetch data from Cloud. That is only useful for simulators because Simulator unable to receive push notifications * Use [CloudKit dashboard](https://icloud.developer.apple.com/dashboard/) to make changes and see it at application, and make change in application and see ones in dashboard. Don't forget to refresh dashboard's page because it doesn't update data on-the-fly. +## Example app using Cacheable Assets +[MediaBook](https://github.com/deeje/MediaBook) is a production-level iOS app being developed, which demonstrates how to handle cacheable assets in collection views. + ## Tests -CloudKit objects can't be mocked up, that's why I create 2 different types of tests: +CloudKit objects can't be mocked up, that's why there are 2 different types of tests: * `Tests/Unit` here I placed tests that can be performed without CloudKit connection. That tests are executed when you submit a Pull Request. * `Tests/CloudKit` here located "manual" tests, they are most important tests that can be run only in configured environment because they work with CloudKit and your Apple ID. @@ -128,7 +315,9 @@ CloudKit objects can't be mocked up, that's why I create 2 different types of te Nothing will be wrong with your account, tests use only private `CKDatabase` for application. **Please run these tests before opening pull requests.** - To run them you need to: + +To run them you need to: + 1. Change `TestableApp` bundle id. 2. Run in simulator or real device `TestableApp` target. 3. Configure iCloud on that device: Settings.app → iCloud → Login. @@ -136,12 +325,21 @@ CloudKit objects can't be mocked up, that's why I create 2 different types of te ## Roadmap -- [x] Move from alpha to beta status. -- [ ] Add `CloudCore.disable` method - [ ] Add methods to clear local cache and remote database - [ ] Add error resolving for `limitExceeded` error (split saves by relationships). -## Author +## Authors + +deeje cooley, [deeje.com](http://www.deeje.com/) +- refactored into Pull/Push termonology +- added offline sync via NSPersistentHistory +- added CloudKit Sharing support +- added Maskable Attributes +- added Cacheable Assets -Open for hire / relocation. Vasily Ulianov, [va...@me.com](http://www.google.com/recaptcha/mailhide/d?k=01eFEpy-HM-qd0Vf6QGABTjw==&c=JrKKY2bjm0Bp58w7zTvPiQ==) +Open for hire / relocation. +- implemented version 1 and 2, with dynamic mapping between CoreData and CloudKit + +Oleg Müller +- added full support for CoreData relationships diff --git a/Source/Classes/AsynchronousOperation.swift b/Source/Classes/AsynchronousOperation.swift index 564924a9..a287e20f 100644 --- a/Source/Classes/AsynchronousOperation.swift +++ b/Source/Classes/AsynchronousOperation.swift @@ -12,7 +12,7 @@ import Foundation /// ## How to use: /// 1. Call `super.main()` when override `main` method, call `super.start()` when override `start` method. /// 2. When operation is finished or cancelled set `self.state = .finished` -class AsynchronousOperation: Operation { +public class AsynchronousOperation: Operation { open override var isAsynchronous: Bool { return true } open override var isExecuting: Bool { return state == .executing } open override var isFinished: Bool { return state == .finished } @@ -28,14 +28,14 @@ class AsynchronousOperation: Operation { } } - enum State: String { + public enum State: String { case ready = "Ready" case executing = "Executing" case finished = "Finished" fileprivate var keyPath: String { return "is" + self.rawValue } } - override func start() { + override public func start() { if self.isCancelled { state = .finished } else { @@ -44,7 +44,7 @@ class AsynchronousOperation: Operation { } } - override func main() { + override public func main() { if self.isCancelled { state = .finished } else { diff --git a/Source/Classes/Caching/CloudCoreCacheManager.swift b/Source/Classes/Caching/CloudCoreCacheManager.swift new file mode 100644 index 00000000..29f68942 --- /dev/null +++ b/Source/Classes/Caching/CloudCoreCacheManager.swift @@ -0,0 +1,350 @@ +// +// CloudCoreCacheManager.swift +// CloudCore +// +// Created by deeje cooley on 4/16/22. +// + +import Foundation +import CoreData +import CloudKit +import Network + +@objc +class CloudCoreCacheManager: NSObject { + + private let persistentContainer: NSPersistentContainer + private let processContext: NSManagedObjectContext + private let container: CKContainer + private let cacheableClassNames: [String] + + private var frcs: [NSFetchedResultsController] = [] + + public init(persistentContainer: NSPersistentContainer, processContext: NSManagedObjectContext) { + self.persistentContainer = persistentContainer + self.processContext = processContext + + self.container = CloudCore.config.container + + var cacheableClassNames: [String] = [] + let entities = persistentContainer.managedObjectModel.entities + for entity in entities { + if let userInfo = entity.userInfo, userInfo[ServiceAttributeNames.keyCacheable] != nil { + cacheableClassNames.append(entity.managedObjectClassName!) + } + } + self.cacheableClassNames = cacheableClassNames + + super.init() + + restartOperations() + configureObservers() + } + + func process(cacheables: [CloudCoreCacheable]) { + for cacheable in cacheables { + switch cacheable.cacheState { + case .upload, .uploading: + upload(cacheableID: cacheable.objectID) + case .download, .downloading: + download(cacheableID: cacheable.objectID) + case .unload: + unload(cacheableID: cacheable.objectID) + default: + break + } + } + } + + func update(_ cacheableIDs: [NSManagedObjectID], change: @escaping (CloudCoreCacheable) -> Void) { + persistentContainer.performBackgroundTask { context in + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + do { + for cacheableID in cacheableIDs { + if let cacheable = try context.existingObject(with: cacheableID) as? CloudCoreCacheable { + change(cacheable) + } + } + + if context.hasChanges { + try context.save() + } + } catch { + CloudCore.delegate?.error(error: error, module: nil) + } + } + } + + private func configureObservers() { + let context = processContext + + context.perform { + for name in self.cacheableClassNames { + let triggerUpload = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.upload.rawValue) + let triggerDownload = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.download.rawValue) + let triggerUnload = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.unload.rawValue) + let triggers = NSCompoundPredicate(orPredicateWithSubpredicates: [triggerUpload, triggerDownload, triggerUnload]) + + let triggerRequest = NSFetchRequest(entityName: name) + triggerRequest.predicate = triggers + triggerRequest.sortDescriptors = [NSSortDescriptor(key: "cacheStateRaw", ascending: true)] + + let frc = NSFetchedResultsController(fetchRequest: triggerRequest, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil) + frc.delegate = self + + try? frc.performFetch() + if let cacheables = frc.fetchedObjects as? [CloudCoreCacheable] { + self.process(cacheables: cacheables) + } + + self.frcs.append(frc) + } + } + } + + func restartOperations() { + let context = processContext + + context.perform { + for name in self.cacheableClassNames { + // retart new & existing ops + let upload = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.upload.rawValue) + let uploading = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.uploading.rawValue) + let download = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.download.rawValue) + let downloading = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.downloading.rawValue) + let newOrExisting = NSCompoundPredicate(orPredicateWithSubpredicates: [upload, uploading, download, downloading]) + let restoreRequest = NSFetchRequest(entityName: name) + restoreRequest.predicate = newOrExisting + if let cacheables = try? context.fetch(restoreRequest) as? [CloudCoreCacheable], !cacheables.isEmpty { + self.process(cacheables: cacheables) + } + + // restart failed uploads + let hasError = NSPredicate(format: "%K != nil", "lastErrorMessage") + let isLocal = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.local.rawValue) + let failedToUpload = NSCompoundPredicate(orPredicateWithSubpredicates: [hasError, isLocal]) + let restartRequest = NSFetchRequest(entityName: name) + restartRequest.predicate = failedToUpload + if let cacheables = try? context.fetch(restartRequest) as? [CloudCoreCacheable], !cacheables.isEmpty { + let cacheableIDs = cacheables.map { $0.objectID } + self.update(cacheableIDs) { cacheable in + cacheable.lastErrorMessage = nil + cacheable.cacheState = .upload + } + } + } + } + } + + func findLongLivedOperation(with operationID: String) -> CKOperation? { + var foundOperation: CKOperation? = nil + + let semaphore = DispatchSemaphore(value: 0) + container.fetchLongLivedOperation(withID: operationID) { operation, error in + if let error = error { + print("Error fetching operation: \(operationID)\n\(error)") + // Handle error + // return + } + + foundOperation = operation + + semaphore.signal() + } + semaphore.wait() + + return foundOperation + } + + func longLivedConfiguration(qos: QualityOfService) -> CKOperation.Configuration { + let configuration = CKOperation.Configuration() + configuration.container = container + configuration.isLongLived = true + configuration.qualityOfService = qos + + return configuration + } + + func upload(cacheableID: NSManagedObjectID) { + // we've been asked to retry later + if let date = CloudCore.pauseUntil, + date.timeIntervalSinceNow > 0 + { return } + + let container = container + let context = processContext + + context.perform { + guard let cacheable = try? context.existingObject(with: cacheableID) as? CloudCoreCacheable else { return } + + var modifyOp: CKModifyRecordsOperation! + if let operationID = cacheable.operationID { + modifyOp = self.findLongLivedOperation(with: operationID) as? CKModifyRecordsOperation + } + + if modifyOp == nil + { + guard let record = try? cacheable.restoreRecordWithSystemFields(for: .private) else { return } + + record[cacheable.assetFieldName] = CKAsset(fileURL: cacheable.url) + record["remoteStatusRaw"] = RemoteStatus.available.rawValue + + modifyOp = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil) + modifyOp.configuration = self.longLivedConfiguration(qos: .utility) + modifyOp.savePolicy = .changedKeys + + cacheable.operationID = modifyOp.operationID + } + + modifyOp.perRecordProgressBlock = { record, progress in + self.update([cacheableID]) { cacheable in + if progress > cacheable.uploadProgress { + cacheable.uploadProgress = progress + } + } + } + modifyOp.perRecordCompletionBlock = { record, error in + self.update([cacheableID]) { cacheable in + cacheable.uploadProgress = 0 + cacheable.cacheState = (error == nil) ? .cached : .local + cacheable.remoteStatus = (error == nil) ? .available : .pending + cacheable.lastErrorMessage = error?.localizedDescription + } + + if let error = error { + CloudCore.delegate?.error(error: error, module: .cacheToCloud) + + if let cloudError = error as? CKError, + let number = cloudError.userInfo[CKErrorRetryAfterKey] as? NSNumber + { + CloudCore.pauseUntil = Date(timeIntervalSinceNow: number.doubleValue) + } + } + } + modifyOp.modifyRecordsCompletionBlock = { records, recordIDs, error in } + modifyOp.longLivedOperationWasPersistedBlock = { } + if !modifyOp.isExecuting { + container.privateCloudDatabase.add(modifyOp) + } + + if cacheable.cacheState != .uploading { + cacheable.cacheState = .uploading + } + if context.hasChanges { + try? context.save() + } + } + } + + func download(cacheableID: NSManagedObjectID) { + // we've been asked to retry later + if let date = CloudCore.pauseUntil, + date.timeIntervalSinceNow > 0 + { return } + + let container = container + let context = processContext + + context.perform { + guard let cacheable = try? context.existingObject(with: cacheableID) as? CloudCoreCacheable else { return } + + var fetchOp: CKFetchRecordsOperation! + if let operationID = cacheable.operationID { + fetchOp = self.findLongLivedOperation(with: operationID) as? CKFetchRecordsOperation + } + + if fetchOp == nil + { + guard let record = try? cacheable.restoreRecordWithSystemFields(for: .private) else { return } + + fetchOp = CKFetchRecordsOperation(recordIDs: [record.recordID]) + fetchOp.configuration = self.longLivedConfiguration(qos: .userInitiated) + fetchOp.desiredKeys = [cacheable.assetFieldName] + + cacheable.operationID = fetchOp.operationID + } + + fetchOp.perRecordProgressBlock = { record, progress in + self.update([cacheableID]) { cacheable in + if progress > cacheable.downloadProgress { + cacheable.downloadProgress = progress + } + } + } + fetchOp.perRecordCompletionBlock = { record, recordID, error in + self.update([cacheableID]) { cacheable in + if let asset = record?[cacheable.assetFieldName] as? CKAsset, + let downloadURL = asset.fileURL + { + let fileManager = FileManager.default + + try? fileManager.moveItem(at: downloadURL, to: cacheable.url) + } + + cacheable.downloadProgress = 0 + cacheable.cacheState = (error == nil) ? .cached : .remote + cacheable.lastErrorMessage = error?.localizedDescription + } + + if let error = error { + CloudCore.delegate?.error(error: error, module: .cacheFromCloud) + + if let cloudError = error as? CKError, + let number = cloudError.userInfo[CKErrorRetryAfterKey] as? NSNumber + { + CloudCore.pauseUntil = Date(timeIntervalSinceNow: number.doubleValue) + } + } + } + fetchOp.longLivedOperationWasPersistedBlock = { } + if !fetchOp.isExecuting { + container.privateCloudDatabase.add(fetchOp) + } + + if cacheable.cacheState != .downloading { + cacheable.cacheState = .downloading + } + if context.hasChanges { + try? context.save() + } + } + } + + func unload(cacheableID: NSManagedObjectID) { + update([cacheableID]) { cacheable in + cacheable.removeLocal() + cacheable.cacheState = .remote + } + } + + public func cancelOperations(with operationIDs: [String]) { + for operationID in operationIDs { + if let op = findLongLivedOperation(with: operationID) { + op.cancel() + } + } + } + +} + +extension CloudCoreCacheManager: NSFetchedResultsControllerDelegate { + + func controller(_ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath?) { + guard let cacheable = anObject as? CloudCoreCacheable else { return } + + if cacheable.cacheState == .upload + || cacheable.cacheState == .download + || cacheable.cacheState == .unload + { + process(cacheables: [cacheable]) + } + } + +} diff --git a/Source/Classes/Caching/CloudCoreCacheable.swift b/Source/Classes/Caching/CloudCoreCacheable.swift new file mode 100644 index 00000000..b49e1abc --- /dev/null +++ b/Source/Classes/Caching/CloudCoreCacheable.swift @@ -0,0 +1,105 @@ +// +// CloudCoreCacheable.swift +// CloudCore +// +// Created by deeje cooley on 4/16/22. +// + +import Foundation +import CoreData + +public enum CacheState: String { + case local + case upload // -> uploading -> cached + case uploading + + case remote + case download // -> downloading -> cached + case downloading + + case cached + + case unload // -> remote +} + +public enum RemoteStatus: String { + case pending + case available +} + +public protocol CloudCoreCacheable: CloudCoreType { + + // fully masked + var cacheStateRaw: String? { get set } + var operationID: String? { get set } + var uploadProgress: Double { get set } + var downloadProgress: Double { get set } + var lastErrorMessage: String? { get set } + + // sync'ed + var remoteStatusRaw: String? { get set } + var suffix: String? { get set } + +} + +public extension CloudCoreCacheable { + + var assetFieldName: String { + return "assetData" + } + + var cacheState: CacheState { + get { + return cacheStateRaw == nil ? .remote : CacheState(rawValue: cacheStateRaw!)! + } + set { + cacheStateRaw = newValue.rawValue + } + } + + var remoteStatus: RemoteStatus { + get { + return remoteStatusRaw == nil ? .pending : RemoteStatus(rawValue: remoteStatusRaw!)! + } + set { + remoteStatusRaw = newValue.rawValue + } + } + + var localAvailable: Bool { + let availableStates: [CacheState] = [.local, .upload, .uploading, .cached, .unload] + + return availableStates.contains(cacheState) + } + + var readyToDownload: Bool { + return remoteStatus == .available && cacheState == .remote + } + + var progress: Double { + switch cacheState { + case .uploading: + return uploadProgress + case .downloading: + return downloadProgress + default: + return 0 + } + } + + var url: URL { + let fileName = recordName! + (suffix ?? "") + + var cacheDirectory = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + cacheDirectory.appendPathComponent(fileName) + + return cacheDirectory + } + + func removeLocal() { + if localAvailable { + try? FileManager.default.removeItem(at: url) + } + } + +} diff --git a/Source/Classes/CloudCore.swift b/Source/Classes/CloudCore.swift index 84421763..03fa829a 100644 --- a/Source/Classes/CloudCore.swift +++ b/Source/Classes/CloudCore.swift @@ -30,7 +30,7 @@ import CloudKit ``` ## Fetch from cloud - When CloudKit data is changed **push notification** is posted to an application. You need to handle it and fetch changed data from CloudKit with `CloudCore.fetchAndSave(using:to:error:completion:)` method. + When CloudKit data is changed **push notification** is posted to an application. You need to handle it and fetch changed data from CloudKit with `CloudCore.pull(using:to:error:completion:)` method. ### Example ```swift @@ -38,21 +38,32 @@ import CloudKit // Check if it CloudKit's and CloudCore notification if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { // Fetch changed data from iCloud - CloudCore.fetchAndSave(using: userInfo, to: persistentContainer, error: nil, completion: { (fetchResult) in + CloudCore.pull(using: userInfo, to: persistentContainer, error: nil, completion: { (fetchResult) in completionHandler(fetchResult.uiBackgroundFetchResult) }) } } ``` - You can also check for updated data at CloudKit **manually** (e.g. push notifications are not working). Use for that `CloudCore.fetchAndSave(to:error:completion:)` + You can also check for updated data at CloudKit **manually** (e.g. push notifications are not working). Use for that `CloudCore.pull(to:error:completion:)` */ open class CloudCore { // MARK: - Properties - private(set) static var coreDataListener: CoreDataListener? - + private(set) static var processContext: NSManagedObjectContext! + + private(set) static var coreDataObserver: CoreDataObserver? + private(set) static var cacheManager: CloudCoreCacheManager? + public static var isOnline: Bool { + get { + return coreDataObserver?.isOnline ?? false + } + set { + coreDataObserver?.isOnline = newValue + } + } + /// CloudCore configuration, it's recommended to set up before calling any of CloudCore methods. You can read more at `CloudCoreConfig` struct description public static var config = CloudCoreConfig() @@ -62,87 +73,128 @@ open class CloudCore { /// Error and sync actions are reported to that delegate public static weak var delegate: CloudCoreDelegate? { didSet { - coreDataListener?.delegate = delegate + coreDataObserver?.delegate = delegate } } public typealias NotificationUserInfo = [AnyHashable : Any] - static private let queue = OperationQueue() + public static var userRecordID: CKRecord.ID? = nil + + static private let queue: OperationQueue = { + let q = OperationQueue() + q.maxConcurrentOperationCount = 1 + return q + }() + // if CloudKit says to retry later… + private static var pauseTimer: Timer? + static var pauseUntil: Date? { + didSet { + DispatchQueue.main.async { + CloudCore.pauseTimer?.invalidate() + if let fireDate = CloudCore.pauseUntil { + let interval = fireDate.timeIntervalSinceNow + print("pausing for \(interval) seconds") + CloudCore.pauseTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { timer in + CloudCore.pauseUntil = nil + + CloudCore.coreDataObserver?.processPersistentHistory() + CloudCore.cacheManager?.restartOperations() + } + } + } + } + } + // MARK: - Methods /// Enable CloudKit and Core Data synchronization /// /// - Parameters: /// - container: `NSPersistentContainer` that will be used to save data - public static func enable(persistentContainer container: NSPersistentContainer) { + public static func enable(persistentContainer: NSPersistentContainer) { + // share a MOC between CoreDataObserver and CacheManager + let processContext = persistentContainer.newBackgroundContext() + processContext.name = "CloudCoreProcess" + processContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + processContext.automaticallyMergesChangesFromParent = true + self.processContext = processContext + // Listen for local changes - let listener = CoreDataListener(container: container) - listener.delegate = self.delegate - listener.observe() - self.coreDataListener = listener + let observer = CoreDataObserver(persistentContainer: persistentContainer, processContext: processContext) + observer.delegate = self.delegate + observer.start() + self.coreDataObserver = observer + self.cacheManager = CloudCoreCacheManager(persistentContainer: persistentContainer, processContext: processContext) + // Subscribe (subscription may be outdated/removed) - #if !os(watchOS) let subscribeOperation = SubscribeOperation() - subscribeOperation.errorBlock = { handle(subscriptionError: $0, container: container) } - queue.addOperation(subscribeOperation) - #endif + subscribeOperation.errorBlock = { + handle(subscriptionError: $0, container: persistentContainer) + } // Fetch updated data (e.g. push notifications weren't received) - let updateFromCloudOperation = FetchAndSaveOperation(persistentContainer: container) - updateFromCloudOperation.errorBlock = { - self.delegate?.error(error: $0, module: .some(.fetchFromCloud)) + let pullOperation = PullChangesOperation(persistentContainer: persistentContainer) + pullOperation.errorBlock = { + self.delegate?.error(error: $0, module: .some(.pullFromCloud)) } - - #if !os(watchOS) - updateFromCloudOperation.addDependency(subscribeOperation) - #endif - - queue.addOperation(updateFromCloudOperation) + + queue.addOperation(subscribeOperation) + queue.addOperation(pullOperation) + + config.container.fetchUserRecordID { recordID, error in + if error == nil { + self.userRecordID = recordID + } + } } /// Disables synchronization (push notifications won't be sent also) public static func disable() { queue.cancelAllOperations() - coreDataListener?.stopObserving() - coreDataListener = nil + coreDataObserver?.stop() + coreDataObserver = nil // FIXME: unsubscribe } + /// return the user's record name + public static func userRecordName() -> String? { + return userRecordID?.recordName + } + // MARK: Fetchers /** Fetch changes from one CloudKit database and save it to CoreData from `didReceiveRemoteNotification` method. - Don't forget to check notification's UserInfo by calling `isCloudCoreNotification(withUserInfo:)`. If incorrect user info is provided `FetchResult.noData` will be returned at completion block. + Don't forget to check notification's UserInfo by calling `isCloudCoreNotification(withUserInfo:)`. If incorrect user info is provided `PullResult.noData` will be returned at completion block. - Parameters: - userInfo: notification's user info, CloudKit database will be extraced from that notification - container: `NSPersistentContainer` that will be used to save fetched data - error: block will be called every time when error occurs during process - - completion: `FetchResult` enumeration with results of operation + - completion: `PullResult` enumeration with results of operation */ - public static func fetchAndSave(using userInfo: NotificationUserInfo, to container: NSPersistentContainer, error: ErrorBlock?, completion: @escaping (_ fetchResult: FetchResult) -> Void) { - guard let cloudDatabase = self.database(for: userInfo) else { + public static func pull(using userInfo: NotificationUserInfo, to container: NSPersistentContainer, error: ErrorBlock?, completion: @escaping (_ fetchResult: PullResult) -> Void) { + guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), let cloudDatabase = self.database(for: notification) else { completion(.noData) return } - - DispatchQueue.global(qos: .utility).async { - let errorProxy = ErrorBlockProxy(destination: error) - let operation = FetchAndSaveOperation(from: [cloudDatabase], persistentContainer: container) - operation.errorBlock = { errorProxy.send(error: $0) } - operation.start() - - if errorProxy.wasError { - completion(FetchResult.failed) - } else { - completion(FetchResult.newData) - } - } + + var hadError = false + let pullChangesOp = PullChangesOperation(from: [cloudDatabase], persistentContainer: container) + pullChangesOp.errorBlock = { + hadError = true + error?($0) + } + pullChangesOp.completionBlock = { + completion(hadError ? PullResult.failed : PullResult.newData) + } + + queue.addOperation(pullChangesOp) } /** Fetch changes from all CloudKit databases and save it to Core Data @@ -150,29 +202,57 @@ open class CloudCore { - Parameters: - container: `NSPersistentContainer` that will be used to save fetched data - error: block will be called every time when error occurs during process - - completion: `FetchResult` enumeration with results of operation + - completion: `PullResult` enumeration with results of operation */ - public static func fetchAndSave(to container: NSPersistentContainer, error: ErrorBlock?, completion: (() -> Void)?) { - let operation = FetchAndSaveOperation(persistentContainer: container) - operation.errorBlock = error - operation.completionBlock = completion - + public static func pull(to container: NSPersistentContainer, error: ErrorBlock?, completion: ((_ fetchResult: PullResult) -> Void)?) { + var hadError = false + let operation = PullChangesOperation(persistentContainer: container) + operation.errorBlock = { + hadError = true + error?($0) + } + operation.completionBlock = { + completion?(hadError ? PullResult.failed : PullResult.newData) + } + queue.addOperation(operation) } + /** Fetch one full record from all CloudKit databases and save it to Core Data + + - Parameters: + - recordID: `CKRecord.ID` identifies the record to retrieve + - database: `CKDatabase` identifies which database from the container to use + - container: `NSPersistentContainer` that will be used to save fetched data + - error: block will be called every time when error occurs during process + - completion: `PullResult` enumeration with results of operation + */ + public static func pull(rootRecordID: CKRecord.ID, + database: CKDatabase = config.container.sharedCloudDatabase, + container: NSPersistentContainer, + error: ErrorBlock?, + completion: (() -> Void)?) { + let operation = PullRecordOperation(rootRecordID: rootRecordID, + database: database, + persistentContainer: container) + operation.errorBlock = error + operation.completionBlock = completion + + queue.addOperation(operation) + } + /** Check if notification is CloudKit notification containing CloudCore data - Parameter userInfo: userInfo of notification - Returns: `true` if notification contains CloudCore data */ public static func isCloudCoreNotification(withUserInfo userInfo: NotificationUserInfo) -> Bool { - return (database(for: userInfo) != nil) + guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else { return false } + + return (database(for: notification) != nil) } - static func database(for notificationUserInfo: NotificationUserInfo) -> CKDatabase? { - guard let notificationDictionary = notificationUserInfo as? [String: NSObject] else { return nil } - let notification = CKNotification(fromRemoteNotificationDictionary: notificationDictionary) - + static func database(for notification: CKNotification) -> CKDatabase? { guard let id = notification.subscriptionID else { return nil } switch id { @@ -196,14 +276,39 @@ open class CloudCore { if case .zoneNotFound = subError.code { // Zone wasn't found, we need to create it self.queue.cancelAllOperations() - let setupOperation = SetupOperation(container: container, parentContext: nil) - self.queue.addOperation(setupOperation) + + let setupOperation = SetupOperation(container: container, uploadAllData: true) // arg, why is this a question?! + // for completeness, pull again + let pullOperation = PullChangesOperation(persistentContainer: container) + pullOperation.errorBlock = { + self.delegate?.error(error: $0, module: .some(.pullFromCloud)) + } + + self.queue.addOperation(setupOperation) + self.queue.addOperation(pullOperation) + return } } delegate?.error(error: subscriptionError, module: nil) } - + + static public func perform(completion: ((CKContainer) -> Void)) { + let container = config.container + + if #available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) { + let ckConfig = CKOperation.Configuration() +// ckConfig.container = container + ckConfig.qualityOfService = .userInitiated + ckConfig.allowsCellularAccess = true + container.configuredWith(configuration: ckConfig, group: nil) { configuredContainer in + completion(configuredContainer) + } + } else { + completion(container) + } + } + } diff --git a/Source/Classes/ErrorBlockProxy.swift b/Source/Classes/ErrorBlockProxy.swift deleted file mode 100644 index 454233cd..00000000 --- a/Source/Classes/ErrorBlockProxy.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// ErrorBlockProxy.swift -// CloudCore -// -// Created by Vasily Ulianov on 12.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import Foundation - -/// Use that class to log if any errors were sent -class ErrorBlockProxy { - private(set) var wasError = false - var destination: ErrorBlock? - - init(destination: ErrorBlock?) { - self.destination = destination - } - - func send(error: Error?) { - if let error = error { - self.wasError = true - destination?(error) - } - } -} diff --git a/Source/Classes/Fetch/FetchAndSaveOperation.swift b/Source/Classes/Fetch/FetchAndSaveOperation.swift deleted file mode 100644 index 50995fc2..00000000 --- a/Source/Classes/Fetch/FetchAndSaveOperation.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// FetchAndSaveOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 13/03/2017. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CloudKit -import CoreData - -/// An operation that fetches data from CloudKit and saves it to Core Data, you can use it without calling `CloudCore.fetchAndSave` methods if you application relies on `Operation` -public class FetchAndSaveOperation: Operation { - - /// Private cloud database for the CKContainer specified by CloudCoreConfig - public static let allDatabases = [ -// CloudCore.config.container.publicCloudDatabase, - CloudCore.config.container.privateCloudDatabase -// CloudCore.config.container.sharedCloudDatabase - ] - - public typealias NotificationUserInfo = [AnyHashable : Any] - - private let tokens: Tokens - private let databases: [CKDatabase] - private let persistentContainer: NSPersistentContainer - - /// Called every time if error occurs - public var errorBlock: ErrorBlock? - - private let queue = OperationQueue() - - /// Initialize operation, it's recommended to set `errorBlock` - /// - /// - Parameters: - /// - databases: list of databases to fetch data from (only private is supported now) - /// - persistentContainer: `NSPersistentContainer` that will be used to save data - /// - tokens: previously saved `Tokens`, you can generate new ones if you want to fetch all data - public init(from databases: [CKDatabase] = FetchAndSaveOperation.allDatabases, persistentContainer: NSPersistentContainer, tokens: Tokens = CloudCore.tokens) { - self.tokens = tokens - self.databases = databases - self.persistentContainer = persistentContainer - - queue.name = "FetchAndSaveQueue" - } - - /// Performs the receiver’s non-concurrent task. - override public func main() { - if self.isCancelled { return } - - CloudCore.delegate?.willSyncFromCloud() - - let backgroundContext = persistentContainer.newBackgroundContext() - backgroundContext.name = CloudCore.config.contextName - - for database in self.databases { - self.addRecordZoneChangesOperation(recordZoneIDs: [CloudCore.config.zoneID], database: database, context: backgroundContext) - } - - self.queue.waitUntilAllOperationsAreFinished() - - do { - try backgroundContext.save() - } catch { - errorBlock?(error) - } - - CloudCore.delegate?.didSyncFromCloud() - } - - private func addRecordZoneChangesOperation(recordZoneIDs: [CKRecordZoneID], database: CKDatabase, context: NSManagedObjectContext) { - if recordZoneIDs.isEmpty { return } - - let recordZoneChangesOperation = FetchRecordZoneChangesOperation(from: database, recordZoneIDs: recordZoneIDs, tokens: tokens) - - recordZoneChangesOperation.recordChangedBlock = { - // Convert and write CKRecord To NSManagedObject Operation - let convertOperation = RecordToCoreDataOperation(parentContext: context, record: $0) - convertOperation.errorBlock = { self.errorBlock?($0) } - self.queue.addOperation(convertOperation) - } - - recordZoneChangesOperation.recordWithIDWasDeletedBlock = { - // Delete NSManagedObject with specified recordID Operation - let deleteOperation = DeleteFromCoreDataOperation(parentContext: context, recordID: $0) - deleteOperation.errorBlock = { self.errorBlock?($0) } - self.queue.addOperation(deleteOperation) - } - - recordZoneChangesOperation.errorBlock = { zoneID, error in - self.handle(recordZoneChangesError: error, in: zoneID, database: database, context: context) - } - - queue.addOperation(recordZoneChangesOperation) - } - - private func handle(recordZoneChangesError: Error, in zoneId: CKRecordZoneID, database: CKDatabase, context: NSManagedObjectContext) { - guard let cloudError = recordZoneChangesError as? CKError else { - errorBlock?(recordZoneChangesError) - return - } - - switch cloudError.code { - // User purged cloud database, we need to delete local cache (according Apple Guidelines) - case .userDeletedZone: - queue.cancelAllOperations() - - let purgeOperation = PurgeLocalDatabaseOperation(parentContext: context, managedObjectModel: persistentContainer.managedObjectModel) - purgeOperation.errorBlock = errorBlock - queue.addOperation(purgeOperation) - - // Our token is expired, we need to refetch everything again - case .changeTokenExpired: - tokens.tokensByRecordZoneID[zoneId] = nil - self.addRecordZoneChangesOperation(recordZoneIDs: [CloudCore.config.zoneID], database: database, context: context) - default: errorBlock?(cloudError) - } - } - -} diff --git a/Source/Classes/Fetch/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift b/Source/Classes/Fetch/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift deleted file mode 100644 index bb1ceed0..00000000 --- a/Source/Classes/Fetch/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// FetchPublicSubscriptionsOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 14/03/2017. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CloudKit - -/// Fetch CloudCore's subscriptions from Public CKDatabase -// TODO: Add Public support in future versions -//class FetchPublicSubscriptionsOperation: AsynchronousOperation { -// var errorBlock: ErrorBlock? -// var fetchCompletionBlock: (([CKSubscription]) -> Void)? -// -// private let prefix = CloudCore.config.publicSubscriptionIDPrefix -// -// override func main() { -// super.main() -// -// CKContainer.default().publicCloudDatabase.fetchAllSubscriptions { (subscriptions, error) in -// defer { -// self.state = .finished -// } -// -// if let error = error { -// self.errorBlock?(error) -// return -// } -// -// guard let subscriptions = subscriptions else { -// self.fetchCompletionBlock?([CKSubscription]()) -// return -// } -// -// var cloudCoreSubscriptions = [CKSubscription]() -// for subscription in subscriptions { -// if !subscription.subscriptionID.hasPrefix(self.prefix) { continue } -// cloudCoreSubscriptions.append(subscription) -// } -// -// self.fetchCompletionBlock?(cloudCoreSubscriptions) -// } -// } -//} diff --git a/Source/Classes/Fetch/PublicSubscriptions/PublicDatabaseSubscriptions.swift b/Source/Classes/Fetch/PublicSubscriptions/PublicDatabaseSubscriptions.swift deleted file mode 100644 index 68837835..00000000 --- a/Source/Classes/Fetch/PublicSubscriptions/PublicDatabaseSubscriptions.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// PublicDatabaseSubscriptions.swift -// CloudCore -// -// Created by Vasily Ulianov on 13/03/2017. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CloudKit - -// TODO: Temporarily disabled, in development - -/// Use that class to manage subscriptions to public CloudKit database. -/// If you want to sync some records with public database you need to subsrcibe for notifications on that changes to enable iCloud -> Local database syncing. -//class PublicDatabaseSubscriptions { -// -// private static var userDefaultsKey: String { return CloudCore.config.userDefaultsKeyTokens } -// private static var prefix: String { return CloudCore.config.publicSubscriptionIDPrefix } -// -// internal(set) static var cachedIDs = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? [String]() -// -// /// Create `CKQuerySubscription` for public database, use it if you want to enable syncing public iCloud -> Core Data -// /// -// /// - Parameters: -// /// - recordType: The string that identifies the type of records to track. You are responsible for naming your app’s record types. This parameter must not be empty string. -// /// - predicate: The matching criteria to apply to the records. This parameter must not be nil. For information about the operators that are supported in search predicates, see the discussion in [CKQuery](apple-reference-documentation://hsDjQFvil9). -// /// - completion: returns subscriptionID and error upon operation completion -// static func subscribe(recordType: String, predicate: NSPredicate, completion: ((_ subscriptionID: String, _ error: Error?) -> Void)?) { -// let id = prefix + UUID().uuidString -// let subscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: id, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]) -// -// let notificationInfo = CKNotificationInfo() -// notificationInfo.shouldSendContentAvailable = true -// subscription.notificationInfo = notificationInfo -// -// let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) -// operation.modifySubscriptionsCompletionBlock = { _, _, error in -// if error == nil { -// self.cachedIDs.append(subscription.subscriptionID) -// UserDefaults.standard.set(self.cachedIDs, forKey: self.userDefaultsKey) -// UserDefaults.standard.synchronize() -// } -// -// completion?(subscription.subscriptionID, error) -// } -// -// operation.timeoutIntervalForResource = 20 -// CKContainer.default().publicCloudDatabase.add(operation) -// } -// -// /// Unsubscribe from public database -// /// -// /// - Parameters: -// /// - subscriptionID: id of subscription to remove -// static func unsubscribe(subscriptionID: String, completion: ((Error?) -> Void)?) { -// let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [], subscriptionIDsToDelete: [subscriptionID]) -// operation.modifySubscriptionsCompletionBlock = { _, _, error in -// if error == nil { -// if let index = self.cachedIDs.index(of: subscriptionID) { -// self.cachedIDs.remove(at: index) -// } -// UserDefaults.standard.set(self.cachedIDs, forKey: self.userDefaultsKey) -// UserDefaults.standard.synchronize() -// } -// -// completion?(error) -// } -// -// operation.timeoutIntervalForResource = 20 -// CKContainer.default().publicCloudDatabase.add(operation) -// } -// -// -// /// Refresh local `cachedIDs` variable with actual data from CloudKit. -// /// Recommended to use after application's UserDefaults reset. -// /// -// /// - Parameter completion: called upon operation completion, contains list of CloudCore subscriptions and error -// static func refreshCache(errorCompletion: ErrorBlock? = nil, successCompletion: (([CKSubscription]) -> Void)? = nil) { -// let operation = FetchPublicSubscriptionsOperation() -// operation.errorBlock = errorCompletion -// operation.fetchCompletionBlock = { subscriptions in -// self.setCache(from: subscriptions) -// successCompletion?(subscriptions) -// } -// operation.start() -// } -// -// internal static func setCache(from subscriptions: [CKSubscription]) { -// let ids = subscriptions.map { $0.subscriptionID } -// self.cachedIDs = ids -// -// UserDefaults.standard.set(ids, forKey: self.userDefaultsKey) -// UserDefaults.standard.synchronize() -// } -//} - diff --git a/Source/Classes/Fetch/SubOperations/DeleteFromCoreDataOperation.swift b/Source/Classes/Fetch/SubOperations/DeleteFromCoreDataOperation.swift deleted file mode 100644 index 9e2155ad..00000000 --- a/Source/Classes/Fetch/SubOperations/DeleteFromCoreDataOperation.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// DeleteFromCoreDataOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 09.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CoreData -import CloudKit - -class DeleteFromCoreDataOperation: Operation { - let parentContext: NSManagedObjectContext - let recordID: CKRecordID - var errorBlock: ErrorBlock? - - init(parentContext: NSManagedObjectContext, recordID: CKRecordID) { - self.parentContext = parentContext - self.recordID = recordID - - super.init() - - self.name = "DeleteFromCoreDataOperation" - } - - override func main() { - if self.isCancelled { return } - - let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - childContext.parent = parentContext - - // Iterate through each entity to fetch and delete object with our recordData - guard let entities = childContext.persistentStoreCoordinator?.managedObjectModel.entities else { return } - for entity in entities { - guard let serviceAttributeNames = entity.serviceAttributeNames else { continue } - - do { - let deleted = try self.delete(entityName: serviceAttributeNames.entityName, - attributeNames: serviceAttributeNames, - in: childContext) - - // only 1 record with such recordData may exists, if delete we don't need to fetch other entities - if deleted { break } - } catch { - self.errorBlock?(error) - continue - } - } - - do { - try childContext.save() - } catch { - self.errorBlock?(error) - } - } - - /// Delete NSManagedObject with specified recordData from entity - /// - /// - Returns: `true` if object is found and deleted, `false` is object is not found - private func delete(entityName: String, attributeNames: ServiceAttributeNames, in context: NSManagedObjectContext) throws -> Bool { - let fetchRequest = NSFetchRequest(entityName: entityName) - fetchRequest.includesPropertyValues = false - fetchRequest.predicate = NSPredicate(format: attributeNames.recordID + " = %@", recordID.encodedString) - - guard let objects = try context.fetch(fetchRequest) as? [NSManagedObject] else { return false } - if objects.isEmpty { return false } - - for object in objects { - context.delete(object) - } - - return true - } - -} diff --git a/Source/Classes/Fetch/SubOperations/FetchRecordZoneChangesOperation.swift b/Source/Classes/Fetch/SubOperations/FetchRecordZoneChangesOperation.swift deleted file mode 100644 index 67f0a407..00000000 --- a/Source/Classes/Fetch/SubOperations/FetchRecordZoneChangesOperation.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// FetchRecordZoneChangesOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 09.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CloudKit - -class FetchRecordZoneChangesOperation: Operation { - // Set on init - let tokens: Tokens - let recordZoneIDs: [CKRecordZoneID] - let database: CKDatabase - // - - var errorBlock: ((CKRecordZoneID, Error) -> Void)? - var recordChangedBlock: ((CKRecord) -> Void)? - var recordWithIDWasDeletedBlock: ((CKRecordID) -> Void)? - - private let optionsByRecordZoneID: [CKRecordZoneID: CKFetchRecordZoneChangesOptions] - private let fetchQueue = OperationQueue() - - init(from database: CKDatabase, recordZoneIDs: [CKRecordZoneID], tokens: Tokens) { - self.tokens = tokens - self.database = database - self.recordZoneIDs = recordZoneIDs - - var optionsByRecordZoneID = [CKRecordZoneID: CKFetchRecordZoneChangesOptions]() - for zoneID in recordZoneIDs { - let options = CKFetchRecordZoneChangesOptions() - options.previousServerChangeToken = self.tokens.tokensByRecordZoneID[zoneID] - optionsByRecordZoneID[zoneID] = options - } - self.optionsByRecordZoneID = optionsByRecordZoneID - - super.init() - - self.name = "FetchRecordZoneChangesOperation" - } - - override func main() { - super.main() - - let fetchOperation = self.makeFetchOperation(optionsByRecordZoneID: optionsByRecordZoneID) - self.fetchQueue.addOperation(fetchOperation) - - fetchQueue.waitUntilAllOperationsAreFinished() - } - - private func makeFetchOperation(optionsByRecordZoneID: [CKRecordZoneID: CKFetchRecordZoneChangesOptions]) -> CKFetchRecordZoneChangesOperation { - // Init Fetch Operation - let fetchOperation = CKFetchRecordZoneChangesOperation(recordZoneIDs: recordZoneIDs, optionsByRecordZoneID: optionsByRecordZoneID) - - fetchOperation.recordChangedBlock = { - self.recordChangedBlock?($0) - } - fetchOperation.recordWithIDWasDeletedBlock = { recordID, _ in - self.recordWithIDWasDeletedBlock?(recordID) - } - fetchOperation.recordZoneFetchCompletionBlock = { zoneId, serverChangeToken, clientChangeTokenData, isMore, error in - self.tokens.tokensByRecordZoneID[zoneId] = serverChangeToken - - if let error = error { - self.errorBlock?(zoneId, error) - } - - if isMore { - let moreOperation = self.makeFetchOperation(optionsByRecordZoneID: optionsByRecordZoneID) - self.fetchQueue.addOperation(moreOperation) - } - } - - fetchOperation.qualityOfService = self.qualityOfService - fetchOperation.database = self.database - - return fetchOperation - } -} diff --git a/Source/Classes/Fetch/SubOperations/PurgeLocalDatabaseOperation.swift b/Source/Classes/Fetch/SubOperations/PurgeLocalDatabaseOperation.swift deleted file mode 100644 index 6c1b5b94..00000000 --- a/Source/Classes/Fetch/SubOperations/PurgeLocalDatabaseOperation.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// PurgeLocalDatabaseOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 12/12/2017. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CoreData - -class PurgeLocalDatabaseOperation: Operation { - - let parentContext: NSManagedObjectContext - let managedObjectModel: NSManagedObjectModel - var errorBlock: ErrorBlock? - - init(parentContext: NSManagedObjectContext, managedObjectModel: NSManagedObjectModel) { - self.parentContext = parentContext - self.managedObjectModel = managedObjectModel - - super.init() - } - - override func main() { - super.main() - - let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - childContext.parent = parentContext - - for entity in managedObjectModel.cloudCoreEnabledEntities { - guard let entityName = entity.name else { continue } - - let fetchRequest = NSFetchRequest(entityName: entityName) - fetchRequest.includesPropertyValues = false - - do { - // I don't user `NSBatchDeleteRequest` because we can't notify viewContextes about changes - guard let objects = try childContext.fetch(fetchRequest) as? [NSManagedObject] else { continue } - - for object in objects { - childContext.delete(object) - } - } catch { - errorBlock?(error) - } - } - - do { - try childContext.save() - } catch { - errorBlock?(error) - } - } - - - -} diff --git a/Source/Classes/Fetch/SubOperations/RecordToCoreDataOperation.swift b/Source/Classes/Fetch/SubOperations/RecordToCoreDataOperation.swift deleted file mode 100644 index bbc99627..00000000 --- a/Source/Classes/Fetch/SubOperations/RecordToCoreDataOperation.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// RecordToCoreDataOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 08.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CoreData -import CloudKit - -/// Convert CKRecord to NSManagedObject and save it to parent context, thread-safe -class RecordToCoreDataOperation: AsynchronousOperation { - let parentContext: NSManagedObjectContext - let record: CKRecord - var errorBlock: ErrorBlock? - - /// - Parameters: - /// - parentContext: operation will be safely performed in that context, **operation doesn't save that context** you need to do it manually - /// - record: record that will be converted to `NSManagedObject` - init(parentContext: NSManagedObjectContext, record: CKRecord) { - self.parentContext = parentContext - self.record = record - - super.init() - - self.name = "RecordToCoreDataOperation" - } - - override func main() { - if self.isCancelled { return } - - parentContext.perform { - do { - try self.setManagedObject(in: self.parentContext) - } catch { - self.errorBlock?(error) - } - - self.state = .finished - } - } - - /// Create or update existing NSManagedObject from CKRecord - /// - /// - Parameter context: child context to perform fetch operations - private func setManagedObject(in context: NSManagedObjectContext) throws { - let entityName = record.recordType - - guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { - throw CloudCoreError.coreData("Unable to find entity specified in CKRecord: " + entityName) - } - guard let serviceAttributes = NSEntityDescription.entity(forEntityName: entityName, in: context)?.serviceAttributeNames else { - throw CloudCoreError.missingServiceAttributes(entityName: entityName) - } - - // Try to find existing objects - let fetchRequest = NSFetchRequest(entityName: entityName) - fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordID + " == %@", record.recordID.encodedString) - - if let foundObject = try context.fetch(fetchRequest).first as? NSManagedObject { - try fill(object: foundObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) - } else { - let newObject = NSManagedObject(entity: entity, insertInto: context) - try fill(object: newObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) - } - } - - - /// Fill provided `NSManagedObject` with data - /// - /// - Parameters: - /// - entityName: entity name of `object` - /// - recordDataAttributeName: attribute name containing recordData - private func fill(object: NSManagedObject, entityName: String, serviceAttributeNames: ServiceAttributeNames, context: NSManagedObjectContext) throws { - for key in record.allKeys() { - let recordValue = record.value(forKey: key) - - let attribute = CloudKitAttribute(value: recordValue, fieldName: key, entityName: entityName, serviceAttributes: serviceAttributeNames, context: context) - let coreDataValue = try attribute.makeCoreDataValue() - object.setValue(coreDataValue, forKey: key) - } - - // Set system headers - object.setValue(record.recordID.encodedString, forKey: serviceAttributeNames.recordID) - object.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.recordData) - } -} diff --git a/Source/Classes/Pull/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift b/Source/Classes/Pull/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift new file mode 100644 index 00000000..e7767ef6 --- /dev/null +++ b/Source/Classes/Pull/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift @@ -0,0 +1,53 @@ +// +// FetchPublicSubscriptionsOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 14/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +/// Fetch CloudCore's subscriptions from Public CKDatabase + +class FetchPublicSubscriptionsOperation: AsynchronousOperation { + var errorBlock: ErrorBlock? + var fetchCompletionBlock: (([CKSubscription]) -> Void)? + + private let prefix = CloudCore.config.publicSubscriptionIDPrefix + + public override init() { + super.init() + + name = "FetchPublicSubscriptionsOperation" + qualityOfService = .userInitiated + } + + override func main() { + super.main() + + CloudCore.config.container.publicCloudDatabase.fetchAllSubscriptions { (subscriptions, error) in + defer { + self.state = .finished + } + + if let error = error { + self.errorBlock?(error) + return + } + + guard let subscriptions = subscriptions else { + self.fetchCompletionBlock?([CKSubscription]()) + return + } + + var cloudCoreSubscriptions = [CKSubscription]() + for subscription in subscriptions { + if !subscription.subscriptionID.hasPrefix(self.prefix) { continue } + cloudCoreSubscriptions.append(subscription) + } + + self.fetchCompletionBlock?(cloudCoreSubscriptions) + } + } +} diff --git a/Source/Classes/Pull/PublicSubscriptions/PublicDatabaseSubscriptions.swift b/Source/Classes/Pull/PublicSubscriptions/PublicDatabaseSubscriptions.swift new file mode 100644 index 00000000..f69bdb3b --- /dev/null +++ b/Source/Classes/Pull/PublicSubscriptions/PublicDatabaseSubscriptions.swift @@ -0,0 +1,103 @@ +// +// PublicDatabaseSubscriptions.swift +// CloudCore +// +// Created by Vasily Ulianov on 13/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +// Use that class to manage subscriptions to public CloudKit database. +// If you want to sync some records with public database you need to subsrcibe for notifications on that changes to enable iCloud -> Local database syncing. +public class PublicDatabaseSubscriptions { + + private static var prefix: String { return CloudCore.config.publicSubscriptionIDPrefix } + + static var cachedIDs = [String]() + + // Create `CKQuerySubscription` for public database, use it if you want to enable syncing public iCloud -> Core Data + // + // - Parameters: + // - recordType: The string that identifies the type of records to track. You are responsible for naming your app’s record types. This parameter must not be empty string. + // - predicate: The matching criteria to apply to the records. This parameter must not be nil. For information about the operators that are supported in search predicates, see the discussion in [CKQuery](apple-reference-documentation://hsDjQFvil9). + // - completion: returns subscriptionID and error upon operation completion + static public func subscribe(recordType: String, predicate: NSPredicate, completion: ((_ subscriptionID: String, _ error: Error?) -> Void)?) { + let id = prefix + recordType + "-" + predicate.predicateFormat + if self.cachedIDs.firstIndex(of: id) != nil { return } + + let options: CKQuerySubscription.Options = [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] + let querySubscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: id, options: options) + + let notificationInfo = CKSubscription.NotificationInfo() + notificationInfo.shouldSendContentAvailable = true + querySubscription.notificationInfo = notificationInfo + + let modifySubscriptions = CKModifySubscriptionsOperation(subscriptionsToSave: [querySubscription], subscriptionIDsToDelete: []) + modifySubscriptions.modifySubscriptionsCompletionBlock = { _, _, error in + if error == nil { + self.cachedIDs.append(querySubscription.subscriptionID) + } + + completion?(querySubscription.subscriptionID, error) + } + + let config = CKOperation.Configuration() + config.timeoutIntervalForResource = 20 + config.qualityOfService = .userInitiated + modifySubscriptions.configuration = config + + CloudCore.config.container.publicCloudDatabase.add(modifySubscriptions) + } + + // Unsubscribe from public database + // + // - Parameters: + // - subscriptionID: id of subscription to remove + static public func unsubscribe(subscriptionID: String, completion: ((Error?) -> Void)?) { + let modifySubscription = CKModifySubscriptionsOperation(subscriptionsToSave: [], subscriptionIDsToDelete: [subscriptionID]) + modifySubscription.modifySubscriptionsCompletionBlock = { _, _, error in + if error == nil { + if let index = self.cachedIDs.firstIndex(of: subscriptionID) { + self.cachedIDs.remove(at: index) + } + } + + completion?(error) + } + + let config = CKOperation.Configuration() + config.timeoutIntervalForResource = 20 + config.qualityOfService = .userInitiated + modifySubscription.configuration = config + + CloudCore.config.container.publicCloudDatabase.add(modifySubscription) + } + + + static public func unsubscribe(recordType: String, predicate: NSPredicate, completion: ((Error?) -> Void)?) { + let id = prefix + recordType + "-" + predicate.predicateFormat + + self.unsubscribe(subscriptionID: id, completion: completion) + } + + + // Refresh local `cachedIDs` variable with actual data from CloudKit. + // Recommended to use after application's UserDefaults reset. + // + // - Parameter completion: called upon operation completion, contains list of CloudCore subscriptions and error + static public func refreshCache(errorCompletion: ErrorBlock? = nil, successCompletion: (([CKSubscription]) -> Void)? = nil) { + let operation = FetchPublicSubscriptionsOperation() + operation.errorBlock = errorCompletion + operation.fetchCompletionBlock = { subscriptions in + self.setCache(from: subscriptions) + successCompletion?(subscriptions) + } + operation.start() + } + + internal static func setCache(from subscriptions: [CKSubscription]) { + self.cachedIDs = subscriptions.map { $0.subscriptionID } + } + +} diff --git a/Source/Classes/Pull/PullChangesOperation.swift b/Source/Classes/Pull/PullChangesOperation.swift new file mode 100644 index 00000000..c4a11f14 --- /dev/null +++ b/Source/Classes/Pull/PullChangesOperation.swift @@ -0,0 +1,262 @@ +// +// PullChangesOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 13/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit +import CoreData + +/// An operation that fetches data from CloudKit and saves it to Core Data, you can use it without calling `CloudCore.pull` methods if you application relies on `Operation` +public class PullChangesOperation: PullOperation { + + /// Private cloud database for the CKContainer specified by CloudCoreConfig + public static let allDatabases = [ + CloudCore.config.container.publicCloudDatabase, + CloudCore.config.container.privateCloudDatabase, + CloudCore.config.container.sharedCloudDatabase + ] + + private let databases: [CKDatabase] + private let tokens: Tokens + + /// Initialize operation, it's recommended to set `errorBlock` + /// + /// - Parameters: + /// - databases: list of databases to fetch data from (only private is supported now) + /// - persistentContainer: `NSPersistentContainer` that will be used to save data + /// - tokens: previously saved `Tokens`, you can generate new ones if you want to fetch all data + public init(from databases: [CKDatabase] = PullChangesOperation.allDatabases, + persistentContainer: NSPersistentContainer, + tokens: Tokens = CloudCore.tokens) { + self.databases = databases + self.tokens = tokens + + super.init(persistentContainer: persistentContainer) + + name = "PullChangesOperation" + } + + /// Performs the receiver’s non-concurrent task. + override public func main() { + if isCancelled { return } + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + CloudCore.delegate?.willSyncFromCloud() + + let backgroundContext = persistentContainer.newBackgroundContext() + backgroundContext.name = CloudCore.config.pullContextName + backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + for database in databases { + let databaseChangeToken = tokens.token(for: database.databaseScope) + + if database.databaseScope == .public { + let changedRecordIDs: NSMutableSet = [] + let deletedRecordIDs: NSMutableSet = [] + let fetchNotificationChanges = CKFetchNotificationChangesOperation(previousServerChangeToken: databaseChangeToken) + fetchNotificationChanges.qualityOfService = .userInitiated + fetchNotificationChanges.notificationChangedBlock = { innerNotification in + if let innerQueryNotification = innerNotification as? CKQueryNotification { + if innerQueryNotification.queryNotificationReason == .recordDeleted { + deletedRecordIDs.add(innerQueryNotification.recordID!) + changedRecordIDs.remove(innerQueryNotification.recordID!) + } else { + changedRecordIDs.add(innerQueryNotification.recordID!) + } + } + } + fetchNotificationChanges.fetchNotificationChangesCompletionBlock = { changeToken, error in + let allChangedRecordIDs = changedRecordIDs.allObjects as! [CKRecord.ID] + let fetchRecords = CKFetchRecordsOperation(recordIDs: allChangedRecordIDs) + fetchRecords.database = CloudCore.config.container.publicCloudDatabase + fetchRecords.qualityOfService = .userInitiated + fetchRecords.perRecordCompletionBlock = { record, recordID, error in + if error == nil { + self.addConvertRecordOperation(record: record!, context: backgroundContext) + } + } + fetchRecords.fetchRecordsCompletionBlock = { _, error in + self.processMissingReferences(context: backgroundContext) + } + let finished = BlockOperation { } + finished.addDependency(fetchRecords) + database.add(fetchRecords) + self.queue.addOperation(finished) + + let allDeletedRecordIDs = deletedRecordIDs.allObjects as! [CKRecord.ID] + for recordID in allDeletedRecordIDs { + self.addDeleteRecordOperation(recordID: recordID, context: backgroundContext) + } + + self.tokens.setToken(changeToken, for: database.databaseScope) + } + let finished = BlockOperation { } + finished.addDependency(fetchNotificationChanges) + CloudCore.config.container.add(fetchNotificationChanges) + queue.addOperation(finished) + } else { + var changedZoneIDs = [CKRecordZone.ID]() + var deletedZoneIDs = [CKRecordZone.ID]() + + let fetchDatabaseChanges = CKFetchDatabaseChangesOperation(previousServerChangeToken: databaseChangeToken) + fetchDatabaseChanges.database = database + fetchDatabaseChanges.qualityOfService = .userInitiated + fetchDatabaseChanges.recordZoneWithIDChangedBlock = { recordZoneID in + changedZoneIDs.append(recordZoneID) + } + fetchDatabaseChanges.recordZoneWithIDWasDeletedBlock = { recordZoneID in + deletedZoneIDs.append(recordZoneID) + } + fetchDatabaseChanges.fetchDatabaseChangesCompletionBlock = { changeToken, moreComing, error in + // TODO: error handling? + + if changedZoneIDs.count > 0 { + self.addRecordZoneChangesOperation(recordZoneIDs: changedZoneIDs, database: database, context: backgroundContext) + } + if deletedZoneIDs.count > 0 { + self.deleteRecordsFromDeletedZones(recordZoneIDs: deletedZoneIDs) + } + + self.tokens.setToken(changeToken, for: database.databaseScope) + } + /* + To improve performance overall, and on watchOS in particular + make sure to queue up CK operations on the proper queue + whether its for the container in general or a specific database. + + To maintain the overall logic of CloudCore, we shadow these ops + in our own queues, using no-op block ops with dependencies. + + You will see this pattern elsewhere in CloudCore when appropriate. + */ + let finished = BlockOperation { } + finished.addDependency(fetchDatabaseChanges) + database.add(fetchDatabaseChanges) + queue.addOperation(finished) + } + } + + queue.waitUntilAllOperationsAreFinished() + + backgroundContext.performAndWait { + do { + try backgroundContext.save() + } catch { + errorBlock?(error) + } + } + + tokens.saveToUserDefaults() + + CloudCore.delegate?.didSyncFromCloud() + } + + private func addDeleteRecordOperation(recordID: CKRecord.ID, context: NSManagedObjectContext) { + // Delete NSManagedObject with specified recordID Operation + let deleteOperation = DeleteFromCoreDataOperation(parentContext: context, recordID: recordID) + deleteOperation.errorBlock = { self.errorBlock?($0) } + queue.addOperation(deleteOperation) + } + + private func addRecordZoneChangesOperation(recordZoneIDs: [CKRecordZone.ID], database: CKDatabase, context: NSManagedObjectContext) { + if recordZoneIDs.isEmpty { return } + + let desiredKeys = context.persistentStoreCoordinator?.managedObjectModel.desiredKeys + + let recordZoneChangesOperation = FetchRecordZoneChangesOperation(from: database, + recordZoneIDs: recordZoneIDs, + tokens: tokens, + desiredKeys: desiredKeys) + recordZoneChangesOperation.qualityOfService = .userInitiated + recordZoneChangesOperation.recordChangedBlock = { + self.addConvertRecordOperation(record: $0, context: context) + } + + recordZoneChangesOperation.recordWithIDWasDeletedBlock = { + self.addDeleteRecordOperation(recordID: $0, context: context) + } + + recordZoneChangesOperation.errorBlock = { zoneID, error in + self.handle(recordZoneChangesError: error, in: zoneID, database: database, context: context) + } + + recordZoneChangesOperation.completionBlock = { + self.processMissingReferences(context: context) + + context.performAndWait { + do { + try context.save() + } catch { + self.errorBlock?(error) + } + } + } + + queue.addOperation(recordZoneChangesOperation) + } + + private func deleteRecordsFromDeletedZones(recordZoneIDs: [CKRecordZone.ID]) { + persistentContainer.performBackgroundTask { moc in + for entity in self.persistentContainer.managedObjectModel.entities { + if let serviceAttributes = entity.serviceAttributeNames { + for recordZoneID in recordZoneIDs { + do { + let request = NSFetchRequest(entityName: entity.name!) + request.predicate = NSPredicate(format: "%K == %@", serviceAttributes.ownerName, recordZoneID.ownerName) + let results = try moc.fetch(request) as! [NSManagedObject] + for object in results { + moc.delete(object) + } + } catch { + print("Unexpected error: \(error).") + } + } + } + } + + do { + try moc.save() + } catch { + print("Unexpected error: \(error).") + } + } + } + + private func handle(recordZoneChangesError: Error, in zoneID: CKRecordZone.ID, database: CKDatabase, context: NSManagedObjectContext) { + guard let cloudError = recordZoneChangesError as? CKError else { + errorBlock?(recordZoneChangesError) + return + } + + switch cloudError.code { + // User purged cloud database, we need to delete local cache (according Apple Guidelines) + case .userDeletedZone: + queue.cancelAllOperations() + + let purgeOperation = PurgeLocalDatabaseOperation(parentContext: context, managedObjectModel: persistentContainer.managedObjectModel) + purgeOperation.errorBlock = errorBlock + queue.addOperation(purgeOperation) + + // Our token is expired, we need to refetch everything again + case .changeTokenExpired: + tokens.setToken(nil, for: zoneID) + addRecordZoneChangesOperation(recordZoneIDs: [zoneID], database: database, context: context) + + default: + errorBlock?(cloudError) + } + } + +} diff --git a/Source/Classes/Pull/PullOperation.swift b/Source/Classes/Pull/PullOperation.swift new file mode 100644 index 00000000..063d59bf --- /dev/null +++ b/Source/Classes/Pull/PullOperation.swift @@ -0,0 +1,86 @@ +// +// PullOperation.swift +// CloudCore +// +// Created by deeje cooley on 3/23/21. +// + +import CloudKit +import CoreData + +public class PullOperation: Operation { + + internal let persistentContainer: NSPersistentContainer + + /// Called every time if error occurs + public var errorBlock: ErrorBlock? + + internal let queue = OperationQueue() + + internal var objectsWithMissingReferences = [MissingReferences]() + + public init(persistentContainer: NSPersistentContainer) { + self.persistentContainer = persistentContainer + + super.init() + + qualityOfService = .userInitiated + + queue.name = "PullQueue" + queue.maxConcurrentOperationCount = 1 + } + + internal func addConvertRecordOperation(record: CKRecord, context: NSManagedObjectContext) { + // Convert and write CKRecord To NSManagedObject Operation + let convertOperation = RecordToCoreDataOperation(parentContext: context, record: record) + convertOperation.errorBlock = { self.errorBlock?($0) } + convertOperation.completionBlock = { + context.performAndWait { + self.objectsWithMissingReferences.append(convertOperation.missingObjectsPerEntities) + } + } + self.queue.addOperation(convertOperation) + } + + internal func processMissingReferences(context: NSManagedObjectContext) { + // iterate over all missing references and fix them, now are all NSManagedObjects created + context.performAndWait { + for missingReferences in objectsWithMissingReferences { + for (object, references) in missingReferences { + guard let serviceAttributes = object.entity.serviceAttributeNames else { continue } + + for (attributeName, recordNames) in references { + for recordName in recordNames { + guard let relationship = object.entity.relationshipsByName[attributeName], let targetEntityName = relationship.destinationEntity?.name else { continue } + + // TODO: move to extension + let fetchRequest = NSFetchRequest(entityName: targetEntityName) + fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordName + " == %@" , recordName) + fetchRequest.fetchLimit = 1 + fetchRequest.includesPropertyValues = false + + do { + let foundObject = try context.fetch(fetchRequest).first as? NSManagedObject + + if let foundObject = foundObject { + if relationship.isToMany { + let set = object.value(forKey: attributeName) as? NSMutableSet ?? NSMutableSet() + set.add(foundObject) + object.setValue(set, forKey: attributeName) + } else { + object.setValue(foundObject, forKey: attributeName) + } + } else { + print("warning: object not found " + recordName) + } + } catch { + self.errorBlock?(error) + } + } + } + } + } + } + } + +} diff --git a/Source/Classes/Pull/PullRecordOperation.swift b/Source/Classes/Pull/PullRecordOperation.swift new file mode 100644 index 00000000..33e8cced --- /dev/null +++ b/Source/Classes/Pull/PullRecordOperation.swift @@ -0,0 +1,99 @@ +// +// PullRecordOperation.swift +// CloudCore +// +// Created by deeje cooley on 3/23/21. +// + +import CloudKit +import CoreData + +/// An operation that fetches data from CloudKit for one record and all its child records, and saves it to Core Data +public class PullRecordOperation: PullOperation { + + let rootRecordID: CKRecord.ID + let database: CKDatabase + + var fetchedRecordIDs: [CKRecord.ID] = [] + + public init(rootRecordID: CKRecord.ID, database: CKDatabase, persistentContainer: NSPersistentContainer) { + self.rootRecordID = rootRecordID + self.database = database + + super.init(persistentContainer: persistentContainer) + + name = "PullRecordOperation" + } + + override public func main() { + if self.isCancelled { return } + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + CloudCore.delegate?.willSyncFromCloud() + + let backgroundContext = persistentContainer.newBackgroundContext() + backgroundContext.name = CloudCore.config.pullContextName + + addFetchRecordsOp(recordIDs: [rootRecordID], backgroundContext: backgroundContext) + + self.queue.waitUntilAllOperationsAreFinished() + + self.processMissingReferences(context: backgroundContext) + + backgroundContext.performAndWait { + do { + try backgroundContext.save() + } catch { + errorBlock?(error) + } + } + + CloudCore.delegate?.didSyncFromCloud() + } + + private func addFetchRecordsOp(recordIDs: [CKRecord.ID], backgroundContext: NSManagedObjectContext) { + let fetchRecords = CKFetchRecordsOperation(recordIDs: recordIDs) + fetchRecords.database = database + fetchRecords.qualityOfService = .userInitiated + fetchRecords.desiredKeys = persistentContainer.managedObjectModel.desiredKeys + fetchRecords.perRecordCompletionBlock = { record, recordID, error in + if let record = record { + self.fetchedRecordIDs.append(recordID!) + + self.addConvertRecordOperation(record: record, context: backgroundContext) + + var childIDs: [CKRecord.ID] = [] + record.allKeys().forEach { key in + if let reference = record[key] as? CKRecord.Reference, !self.fetchedRecordIDs.contains(reference.recordID) { + childIDs.append(reference.recordID) + } + if let array = record[key] as? [CKRecord.Reference] { + array.forEach { reference in + if !self.fetchedRecordIDs.contains(reference.recordID) { + childIDs.append(reference.recordID) + } + } + } + } + + if !childIDs.isEmpty { + self.addFetchRecordsOp(recordIDs: childIDs, backgroundContext: backgroundContext) + } + } + } + let finished = BlockOperation { } + finished.addDependency(fetchRecords) + database.add(fetchRecords) + self.queue.addOperation(finished) + } + +} diff --git a/Source/Classes/Pull/SubOperations/DeleteFromCoreDataOperation.swift b/Source/Classes/Pull/SubOperations/DeleteFromCoreDataOperation.swift new file mode 100644 index 00000000..3db94209 --- /dev/null +++ b/Source/Classes/Pull/SubOperations/DeleteFromCoreDataOperation.swift @@ -0,0 +1,88 @@ +// +// DeleteFromCoreDataOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +class DeleteFromCoreDataOperation: Operation { + let parentContext: NSManagedObjectContext + let recordID: CKRecord.ID + var errorBlock: ErrorBlock? + + init(parentContext: NSManagedObjectContext, recordID: CKRecord.ID) { + self.parentContext = parentContext + self.recordID = recordID + + super.init() + + name = "DeleteFromCoreDataOperation" + qualityOfService = .userInteractive + } + + override func main() { + if self.isCancelled { return } + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + childContext.performAndWait { + childContext.parent = parentContext + + // Iterate through each entity to fetch and delete object with our recordData + guard let entities = childContext.persistentStoreCoordinator?.managedObjectModel.entities else { return } + for entity in entities { + guard let serviceAttributeNames = entity.serviceAttributeNames else { continue } + + do { + let deleted = try self.delete(entityName: serviceAttributeNames.entityName, + attributeNames: serviceAttributeNames, + in: childContext) + + // only 1 record with such recordData may exists, if delete we don't need to fetch other entities + if deleted { break } + } catch { + self.errorBlock?(error) + continue + } + } + + do { + try childContext.save() + } catch { + self.errorBlock?(error) + } + } + } + + /// Delete NSManagedObject with specified recordData from entity + /// + /// - Returns: `true` if object is found and deleted, `false` is object is not found + private func delete(entityName: String, attributeNames: ServiceAttributeNames, in context: NSManagedObjectContext) throws -> Bool { + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.includesPropertyValues = false + fetchRequest.predicate = NSPredicate(format: attributeNames.recordName + " = %@", recordID.recordName) + + guard let objects = try context.fetch(fetchRequest) as? [NSManagedObject] else { return false } + if objects.isEmpty { return false } + + for object in objects { + context.delete(object) + } + + return true + } + +} diff --git a/Source/Classes/Pull/SubOperations/FetchRecordZoneChangesOperation.swift b/Source/Classes/Pull/SubOperations/FetchRecordZoneChangesOperation.swift new file mode 100644 index 00000000..1109f6d1 --- /dev/null +++ b/Source/Classes/Pull/SubOperations/FetchRecordZoneChangesOperation.swift @@ -0,0 +1,103 @@ +// +// FetchRecordZoneChangesOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +class FetchRecordZoneChangesOperation: Operation { + // Set on init + let tokens: Tokens + let recordZoneIDs: [CKRecordZone.ID] + let database: CKDatabase + // + + var errorBlock: ((CKRecordZone.ID, Error) -> Void)? + var recordChangedBlock: ((CKRecord) -> Void)? + var recordWithIDWasDeletedBlock: ((CKRecord.ID) -> Void)? + + private let optionsByRecordZoneID: [CKRecordZone.ID: CKFetchRecordZoneChangesOperation.ZoneConfiguration] + private let fetchQueue = OperationQueue() + + init(from database: CKDatabase, recordZoneIDs: [CKRecordZone.ID], tokens: Tokens, desiredKeys: [String]? = nil) { + self.tokens = tokens + self.database = database + self.recordZoneIDs = recordZoneIDs + + var optionsByRecordZoneID = [CKRecordZone.ID: CKFetchRecordZoneChangesOperation.ZoneConfiguration]() + for zoneID in recordZoneIDs { + let options = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + options.previousServerChangeToken = self.tokens.token(for: zoneID) + optionsByRecordZoneID[zoneID] = options + options.desiredKeys = desiredKeys + } + self.optionsByRecordZoneID = optionsByRecordZoneID + + super.init() + + name = "FetchRecordZoneChangesOperation" + qualityOfService = .userInitiated + } + + override func main() { + super.main() + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + let fetchOperation = self.makeFetchOperation(optionsByRecordZoneID: optionsByRecordZoneID) + let finish = BlockOperation { } + finish.addDependency(fetchOperation) + database.add(fetchOperation) + fetchQueue.addOperation(finish) + + fetchQueue.waitUntilAllOperationsAreFinished() + } + + private func makeFetchOperation(optionsByRecordZoneID: [CKRecordZone.ID: CKFetchRecordZoneChangesOperation.ZoneConfiguration]) -> CKFetchRecordZoneChangesOperation { + // Init Fetch Operation + let fetchRecordZoneChanges = CKFetchRecordZoneChangesOperation(recordZoneIDs: recordZoneIDs, configurationsByRecordZoneID: optionsByRecordZoneID) + + fetchRecordZoneChanges.recordChangedBlock = { + self.recordChangedBlock?($0) + } + fetchRecordZoneChanges.recordWithIDWasDeletedBlock = { recordID, _ in + self.recordWithIDWasDeletedBlock?(recordID) + } + /* + fetchRecordZoneChanges.recordZoneChangeTokensUpdatedBlock = { zoneId, serverChangeToken, _ in + self.tokens.setToken(serverChangeToken, for: zoneId) + } + */ + fetchRecordZoneChanges.recordZoneFetchCompletionBlock = { zoneId, serverChangeToken, clientChangeTokenData, isMore, error in + self.tokens.setToken(serverChangeToken, for: zoneId) + + if let error = error { + self.errorBlock?(zoneId, error) + } + + if isMore { + let moreOperation = self.makeFetchOperation(optionsByRecordZoneID: optionsByRecordZoneID) + let finish = BlockOperation { } + finish.addDependency(moreOperation) + self.database.add(moreOperation) + self.fetchQueue.addOperation(finish) + } + } + + fetchRecordZoneChanges.database = self.database + fetchRecordZoneChanges.qualityOfService = .userInitiated + + return fetchRecordZoneChanges + } +} diff --git a/Source/Classes/Pull/SubOperations/PurgeLocalDatabaseOperation.swift b/Source/Classes/Pull/SubOperations/PurgeLocalDatabaseOperation.swift new file mode 100644 index 00000000..92cf3b24 --- /dev/null +++ b/Source/Classes/Pull/SubOperations/PurgeLocalDatabaseOperation.swift @@ -0,0 +1,72 @@ +// +// PurgeLocalDatabaseOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 12/12/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData + +class PurgeLocalDatabaseOperation: Operation { + + let parentContext: NSManagedObjectContext + let managedObjectModel: NSManagedObjectModel + var errorBlock: ErrorBlock? + + init(parentContext: NSManagedObjectContext, managedObjectModel: NSManagedObjectModel) { + self.parentContext = parentContext + self.managedObjectModel = managedObjectModel + + super.init() + + name = "PurgeLocalDatabaseOperation" + qualityOfService = .userInitiated + } + + override func main() { + super.main() + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + childContext.performAndWait { + childContext.parent = parentContext + + for entity in managedObjectModel.cloudCoreEnabledEntities { + guard let entityName = entity.name else { continue } + + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.includesPropertyValues = false + + do { + // I don't user `NSBatchDeleteRequest` because we can't notify viewContextes about changes + guard let objects = try childContext.fetch(fetchRequest) as? [NSManagedObject] else { continue } + + for object in objects { + childContext.delete(object) + } + } catch { + errorBlock?(error) + } + } + + do { + try childContext.save() + } catch { + errorBlock?(error) + } + } + } + + + +} diff --git a/Source/Classes/Pull/SubOperations/RecordToCoreDataOperation.swift b/Source/Classes/Pull/SubOperations/RecordToCoreDataOperation.swift new file mode 100644 index 00000000..25c73e74 --- /dev/null +++ b/Source/Classes/Pull/SubOperations/RecordToCoreDataOperation.swift @@ -0,0 +1,146 @@ +// +// RecordToCoreDataOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 08.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +typealias AttributeName = String +typealias RecordName = String +typealias MissingReferences = [NSManagedObject: [AttributeName: [RecordName]]] + +/// Convert CKRecord to NSManagedObject and save it to parent context, thread-safe +public class RecordToCoreDataOperation: AsynchronousOperation { + let parentContext: NSManagedObjectContext + let record: CKRecord + var errorBlock: ErrorBlock? + var missingObjectsPerEntities = MissingReferences() + + /// - Parameters: + /// - parentContext: operation will be safely performed in that context, **operation doesn't save that context** you need to do it manually + /// - record: record that will be converted to `NSManagedObject` + public init(parentContext: NSManagedObjectContext, record: CKRecord) { + self.parentContext = parentContext + self.record = record + + super.init() + + name = "RecordToCoreDataOperation" + qualityOfService = .userInteractive + } + + override public func main() { + if self.isCancelled { return } + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + parentContext.performAndWait { + do { + try self.setManagedObject(in: self.parentContext) + } catch { + self.errorBlock?(error) + } + + } + self.state = .finished + } + + /// Create or update existing NSManagedObject from CKRecord + /// + /// - Parameter context: child context to perform fetch operations + private func setManagedObject(in context: NSManagedObjectContext) throws { + let entityName = record.recordType + + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { + throw CloudCoreError.coreData("Unable to find entity specified in CKRecord: " + entityName) + } + guard let serviceAttributes = NSEntityDescription.entity(forEntityName: entityName, in: context)?.serviceAttributeNames else { + throw CloudCoreError.missingServiceAttributes(entityName: entityName) + } + + // Try to find existing objects + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordName + " == %@", record.recordID.recordName) + + if let foundObject = try context.fetch(fetchRequest).first as? NSManagedObject { + try fill(object: foundObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) + } else { + let newObject = NSManagedObject(entity: entity, insertInto: context) + try fill(object: newObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) + } + } + + + /// Fill provided `NSManagedObject` with data + /// + /// - Parameters: + /// - entityName: entity name of `object` + /// - recordDataAttributeName: attribute name containing recordData + private func fill(object: NSManagedObject, entityName: String, serviceAttributeNames: ServiceAttributeNames, context: NSManagedObjectContext) throws { + + func storeValue(_ recordValue: Any?, for key: String) { + if serviceAttributeNames.maskedDownload.contains(key) { return } + + let ckAttribute = CloudKitAttribute(value: recordValue, fieldName: key, entityName: entityName, serviceAttributes: serviceAttributeNames, context: context) + if let coreDataValue = try? ckAttribute.makeCoreDataValue() { + if let cdAttribute = object.entity.attributesByName[key], cdAttribute.attributeType == .transformableAttributeType, + let data = coreDataValue as? Data { + if let name = cdAttribute.valueTransformerName, let transformer = ValueTransformer(forName: NSValueTransformerName(rawValue: name)) { + let value = transformer.transformedValue(coreDataValue) + object.setValue(value, forKey: key) + } else if let unarchivedObject = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSObject.classForKeyedUnarchiver()], from: data) { + object.setValue(unarchivedObject, forKey: key) + } else { + object.setValue(coreDataValue, forKey: key) + } + } else { + if object.entity.attributesByName[key] != nil || object.entity.relationshipsByName[key] != nil { + object.setValue(coreDataValue, forKey: key) + } + missingObjectsPerEntities[object] = ckAttribute.notFoundRecordNamesForAttribute + } + } + } + + if #available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) { + let allKeys = record.allKeys() + let encryptedKeys = record.encryptedValues.allKeys() + + for key in allKeys { + let recordValue: Any? + if encryptedKeys.contains(key) { + recordValue = record.encryptedValues[key] + } else { + recordValue = record.value(forKey: key) + } + storeValue(recordValue, for: key) + } + } else { + for key in record.allKeys() { + let recordValue = record.value(forKey: key) + storeValue(recordValue, for: key) + } + } + + // Set system headers + object.setValue(record.recordID.recordName, forKey: serviceAttributeNames.recordName) + object.setValue(record.recordID.zoneID.ownerName, forKey: serviceAttributeNames.ownerName) + if record.recordID.zoneID.zoneName == CKRecordZone.default().zoneID.zoneName { + object.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.publicRecordData) + } else { + object.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.privateRecordData) + } + } +} diff --git a/Source/Classes/Push/CoreDataObserver.swift b/Source/Classes/Push/CoreDataObserver.swift new file mode 100644 index 00000000..9c2ef7e6 --- /dev/null +++ b/Source/Classes/Push/CoreDataObserver.swift @@ -0,0 +1,365 @@ +// +// CoreDataChangesListener.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CoreData +import CloudKit + +/// Class responsible for taking action on Core Data changes +class CoreDataObserver { + var persistentContainer: NSPersistentContainer + var processContext: NSManagedObjectContext + + let converter = ObjectToRecordConverter() + let pushOperationQueue = PushOperationQueue() + + static let pushContextName = "CloudCorePush" + + var processTimer: Timer? + + var isProcessing = false + var processAgain = true + + // Used for errors delegation + weak var delegate: CloudCoreDelegate? + + var isOnline = true { + didSet { + if isOnline != oldValue && isOnline == true { + processPersistentHistory() + } + } + } + + public init(persistentContainer: NSPersistentContainer, processContext: NSManagedObjectContext) { + self.persistentContainer = persistentContainer + self.processContext = processContext + + converter.errorBlock = { [weak self] in + self?.delegate?.error(error: $0, module: .some(.pushToCloud)) + } + + var usePersistentHistoryForPush = false + if let storeDescription = persistentContainer.persistentStoreDescriptions.first, + let persistentHistoryNumber = storeDescription.options[NSPersistentHistoryTrackingKey] as? NSNumber + { + usePersistentHistoryForPush = persistentHistoryNumber.boolValue + } + assert(usePersistentHistoryForPush) + + processPersistentHistory() + } + + /// Observe Core Data willSave and didSave notifications + func start() { + NotificationCenter.default.addObserver(self, + selector: #selector(self.willSave(notification:)), + name: .NSManagedObjectContextWillSave, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(self.didSave(notification:)), + name: .NSManagedObjectContextDidSave, + object: nil) + } + + /// Remove Core Data observers + func stop() { + NotificationCenter.default.removeObserver(self) + } + + deinit { + stop() + } + + func shouldProcess(_ context: NSManagedObjectContext) -> Bool { + // Ignore saves that are generated by PullController + if context.name != CloudCore.config.pushContextName { return false } + + // Upload only for changes in root context that will be saved to persistentStore + if context.parent != nil { return false } + + return true + } + + func processChanges() -> Bool { + var success = true + + CloudCore.delegate?.willSyncToCloud() + + let backgroundContext = persistentContainer.newBackgroundContext() + backgroundContext.name = CoreDataObserver.pushContextName + backgroundContext.automaticallyMergesChangesFromParent = true + + let records = converter.processPendingOperations(in: backgroundContext) + pushOperationQueue.errorBlock = { + self.handle(error: $0, parentContext: backgroundContext) + success = false + } + pushOperationQueue.addOperations(recordsToSave: records.recordsToSave, recordIDsToDelete: records.recordIDsToDelete) + pushOperationQueue.waitUntilAllOperationsAreFinished() + + if success { + backgroundContext.performAndWait { + do { + if backgroundContext.hasChanges { + try backgroundContext.save() + } + } catch { + delegate?.error(error: error, module: .some(.pushToCloud)) + success = false + } + } + } + + CloudCore.delegate?.didSyncToCloud() + + return success + } + + func process(_ transaction: NSPersistentHistoryTransaction, in moc: NSManagedObjectContext) -> Bool { + var success = true + + if transaction.contextName != CloudCore.config.pushContextName { return success } + + if let changes = transaction.changes { + var insertedObjects = Set() + var updatedObject = Set() + var deletedRecordIDs: [RecordIDWithDatabase] = [] + var operationIDs: [String] = [] + + for change in changes { + switch change.changeType { + case .insert: + if let inserted = try? moc.existingObject(with: change.changedObjectID) { + insertedObjects.insert(inserted) + } + + case .update: + if let inserted = try? moc.existingObject(with: change.changedObjectID) { + if let updatedProperties = change.updatedProperties { + let updatedPropertyNames: [String] = updatedProperties.map { (propertyDescription) in + return propertyDescription.name + } + inserted.updatedPropertyNames = updatedPropertyNames + } + updatedObject.insert(inserted) + } + + case .delete: + if change.tombstone != nil { + if let privateRecordData = change.tombstone!["privateRecordData"] as? Data { + let ckRecord = CKRecord(archivedData: privateRecordData) + let database = ckRecord?.recordID.zoneID.ownerName == CKCurrentUserDefaultName ? CloudCore.config.container.privateCloudDatabase : CloudCore.config.container.sharedCloudDatabase + let recordIDWithDatabase = RecordIDWithDatabase((ckRecord?.recordID)!, database) + deletedRecordIDs.append(recordIDWithDatabase) + } + if let publicRecordData = change.tombstone!["publicRecordData"] as? Data { + let ckRecord = CKRecord(archivedData: publicRecordData) + let recordIDWithDatabase = RecordIDWithDatabase((ckRecord?.recordID)!, CloudCore.config.container.publicCloudDatabase) + deletedRecordIDs.append(recordIDWithDatabase) + } + if let operationID = change.tombstone!["operationID"] as? String { + operationIDs.append(operationID) + } + } + + default: + break + } + } + + self.converter.prepareOperationsFor(inserted: insertedObjects, + updated: updatedObject, + deleted: deletedRecordIDs) + + try? moc.save() + + if self.converter.hasPendingOperations { + success = self.processChanges() + } + + // check for cached assets + if success == true { + moc.perform { + for insertedObject in insertedObjects { + guard let cacheable = insertedObject as? CloudCoreCacheable, + cacheable.cacheState == .local + else { continue } + + cacheable.cacheState = .upload + } + + try? moc.save() + } + } + + if !operationIDs.isEmpty { + CloudCore.cacheManager?.cancelOperations(with: operationIDs) + } + } + + return success + } + + @objc func processPersistentHistory() { + #if os(iOS) + guard isOnline else { return } + #endif + + if isProcessing { + processAgain = true + + return + } + + #if TARGET_OS_IOS + let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "CloudCore.processPersistentHistory") + #endif + + isProcessing = true + + processContext.perform { + let settings = UserDefaults.standard + do { + var token: NSPersistentHistoryToken? = nil + if let data = settings.object(forKey: CloudCore.config.persistentHistoryTokenKey) as? Data { + token = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSPersistentHistoryToken.classForKeyedUnarchiver()], from: data) as? NSPersistentHistoryToken + } + let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: token) + let historyResult = try self.processContext.execute(historyRequest) as! NSPersistentHistoryResult + + if let history = historyResult.result as? [NSPersistentHistoryTransaction] { + for transaction in history { + if self.process(transaction, in: self.processContext) { + let deleteRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: transaction) + try self.processContext.execute(deleteRequest) + + let data = try NSKeyedArchiver.archivedData(withRootObject: transaction.token, requiringSecureCoding: false) + settings.set(data, forKey: CloudCore.config.persistentHistoryTokenKey) + } else { + break + } + } + } + } catch { + let nserror = error as NSError + switch nserror.code { + case NSPersistentHistoryTokenExpiredError: + settings.set(nil, forKey: CloudCore.config.persistentHistoryTokenKey) + default: + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + + #if TARGET_OS_IOS + UIApplication.shared.endBackgroundTask(backgroundTask) + #endif + + DispatchQueue.main.async { + self.isProcessing = false + + if self.processAgain { + self.processAgain = false + + self.processPersistentHistory() + } + } + } + } + + @objc private func willSave(notification: Notification) { + guard let context = notification.object as? NSManagedObjectContext else { return } + guard shouldProcess(context) else { return } + + context.insertedObjects.forEach { (inserted) in + if let serviceAttributeNames = inserted.entity.serviceAttributeNames { + for scope in serviceAttributeNames.scopes { + let _ = try? inserted.setRecordInformation(for: scope) + } + } + } + } + + @objc private func didSave(notification: Notification) { + guard let context = notification.object as? NSManagedObjectContext else { return } + guard shouldProcess(context) else { return } + + // we've been asked to retry later + if let date = CloudCore.pauseUntil, + date.timeIntervalSinceNow > 0 + { return } + + DispatchQueue.main.async { + self.processTimer?.invalidate() + self.processTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in + self.processPersistentHistory() + } + } + } + + private func handle(error: Error, parentContext: NSManagedObjectContext) { + guard let cloudError = error as? CKError else { + delegate?.error(error: error, module: .some(.pushToCloud)) + return + } + + switch cloudError.code { + case .requestRateLimited, .zoneBusy, .serviceUnavailable: + pushOperationQueue.cancelAllOperations() + + if let number = cloudError.userInfo[CKErrorRetryAfterKey] as? NSNumber { + CloudCore.pauseUntil = Date(timeIntervalSinceNow: number.doubleValue) + } + + // Zone was accidentally deleted (NOT PURGED), we need to reupload all data accroding Apple Guidelines + case .zoneNotFound: + pushOperationQueue.cancelAllOperations() + + var resetZoneOperations: [Operation] = [] + + var deleteZoneOperation: Operation? = nil + if let _ = cloudError.userInfo["CKErrorUserDidResetEncryptedDataKey"] { + // per https://developer.apple.com/documentation/cloudkit/encrypting_user_data + // see also https://github.com/apple/cloudkit-sample-encryption + + let deleteOp = DeleteCloudCoreZoneOperation() + resetZoneOperations.append(deleteOp) + + deleteZoneOperation = deleteOp + } + + // Create CloudCore Zone + let createZoneOperation = CreateCloudCoreZoneOperation() + createZoneOperation.errorBlock = { + self.delegate?.error(error: $0, module: .some(.pushToCloud)) + self.pushOperationQueue.cancelAllOperations() + } + if let deleteZoneOperation = deleteZoneOperation { + createZoneOperation.addDependency(deleteZoneOperation) + } + resetZoneOperations.append(createZoneOperation) + + // Subscribe operation + let subscribeOperation = SubscribeOperation() + subscribeOperation.errorBlock = { self.delegate?.error(error: $0, module: .some(.pushToCloud)) } + subscribeOperation.addDependency(createZoneOperation) + resetZoneOperations.append(subscribeOperation) + + // Upload all local data + let uploadOperation = PushAllLocalDataOperation(parentContext: parentContext, managedObjectModel: persistentContainer.managedObjectModel) + uploadOperation.errorBlock = { self.delegate?.error(error: $0, module: .some(.pushToCloud)) } + uploadOperation.addDependency(createZoneOperation) + resetZoneOperations.append(uploadOperation) + + pushOperationQueue.addOperations(resetZoneOperations, waitUntilFinished: true) + case .operationCancelled: return + default: delegate?.error(error: cloudError, module: .some(.pushToCloud)) + } + } + +} diff --git a/Source/Classes/Save/Model/RecordIDWithDatabase.swift b/Source/Classes/Push/Model/RecordIDWithDatabase.swift similarity index 76% rename from Source/Classes/Save/Model/RecordIDWithDatabase.swift rename to Source/Classes/Push/Model/RecordIDWithDatabase.swift index b802e464..e133a3ea 100644 --- a/Source/Classes/Save/Model/RecordIDWithDatabase.swift +++ b/Source/Classes/Push/Model/RecordIDWithDatabase.swift @@ -9,10 +9,10 @@ import CloudKit class RecordIDWithDatabase { - let recordID: CKRecordID + let recordID: CKRecord.ID let database: CKDatabase - init(_ recordID: CKRecordID, _ database: CKDatabase) { + init(_ recordID: CKRecord.ID, _ database: CKDatabase) { self.recordID = recordID self.database = database } diff --git a/Source/Classes/Save/Model/RecordWithDatabase.swift b/Source/Classes/Push/Model/RecordWithDatabase.swift similarity index 100% rename from Source/Classes/Save/Model/RecordWithDatabase.swift rename to Source/Classes/Push/Model/RecordWithDatabase.swift diff --git a/Source/Classes/Save/ObjectToRecord/CoreDataAttribute.swift b/Source/Classes/Push/ObjectToRecord/CoreDataAttribute.swift similarity index 74% rename from Source/Classes/Save/ObjectToRecord/CoreDataAttribute.swift rename to Source/Classes/Push/ObjectToRecord/CoreDataAttribute.swift index cf7dd05f..d079521d 100644 --- a/Source/Classes/Save/ObjectToRecord/CoreDataAttribute.swift +++ b/Source/Classes/Push/ObjectToRecord/CoreDataAttribute.swift @@ -23,14 +23,22 @@ class CoreDataAttribute { // it is not an attribute return nil } - - self.description = description + + self.description = description if value is NSNull { self.value = nil - } else { - self.value = value - } + } else if let attribute = entity.attributesByName[attributeName], + attribute.attributeType == .transformableAttributeType { + if let transformerName = attribute.valueTransformerName, + let transformer = ValueTransformer(forName: NSValueTransformerName(rawValue: transformerName)) { + self.value = transformer.reverseTransformedValue(value) + } else { + self.value = try? NSKeyedArchiver.archivedData(withRootObject: value!, requiringSecureCoding: false) + } + } else { + self.value = value + } self.name = attributeName } diff --git a/Source/Classes/Save/ObjectToRecord/CoreDataRelationship.swift b/Source/Classes/Push/ObjectToRecord/CoreDataRelationship.swift similarity index 76% rename from Source/Classes/Save/ObjectToRecord/CoreDataRelationship.swift rename to Source/Classes/Push/ObjectToRecord/CoreDataRelationship.swift index d2087cc4..16f04512 100644 --- a/Source/Classes/Save/ObjectToRecord/CoreDataRelationship.swift +++ b/Source/Classes/Push/ObjectToRecord/CoreDataRelationship.swift @@ -10,19 +10,19 @@ import CoreData import CloudKit class CoreDataRelationship { - typealias Class = CoreDataRelationship - + let scope: CKDatabase.Scope let value: Any let description: NSRelationshipDescription /// Initialize Core Data Attribute with properties and value /// - Returns: `nil` if it is not an attribute (possible it is relationship?) - init?(value: Any, relationshipName: String, entity: NSEntityDescription) { - guard let description = Class.relationshipDescription(for: relationshipName, in: entity) else { + init?(scope: CKDatabase.Scope, value: Any, relationshipName: String, entity: NSEntityDescription) { + guard let description = CoreDataRelationship.relationshipDescription(for: relationshipName, in: entity) else { // it is not a relationship return nil } + self.scope = scope self.description = description self.value = value } @@ -46,7 +46,7 @@ class CoreDataRelationship { guard let objectsSet = value as? NSSet else { return nil } - var referenceList = [CKReference]() + var referenceList = [CKRecord.Reference]() for (_, managedObject) in objectsSet.enumerated() { guard let managedObject = managedObject as? NSManagedObject, let reference = try makeReference(from: managedObject) else { continue } @@ -64,22 +64,22 @@ class CoreDataRelationship { } } - private func makeReference(from managedObject: NSManagedObject) throws -> CKReference? { - let action: CKReferenceAction + private func makeReference(from managedObject: NSManagedObject) throws -> CKRecord.Reference? { + let action: CKRecord.ReferenceAction if case .some(NSDeleteRule.cascadeDeleteRule) = description.inverseRelationship?.deleteRule { action = .deleteSelf } else { action = .none } - - guard let record = try managedObject.restoreRecordWithSystemFields() else { + + guard let record = try managedObject.restoreRecordWithSystemFields(for: scope) else { // That is possible if method is called before all managed object were filled with recordData // That may cause possible reference corruption (Core Data -> iCloud), but it is not critical assertionFailure("Managed Object doesn't have stored record information, should be reported as a framework bug") return nil } - return CKReference(record: record, action: action) + return CKRecord.Reference(record: record, action: action) } } diff --git a/Source/Classes/Push/ObjectToRecord/ObjectToRecordConverter.swift b/Source/Classes/Push/ObjectToRecord/ObjectToRecordConverter.swift new file mode 100644 index 00000000..bbfee950 --- /dev/null +++ b/Source/Classes/Push/ObjectToRecord/ObjectToRecordConverter.swift @@ -0,0 +1,159 @@ +// +// ObjectToRecordConverter.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +class ObjectToRecordConverter { + enum ManagedObjectChangeType { + case inserted, updated + } + + var errorBlock: ErrorBlock? + + private var pendingConvertOperations = [ObjectToRecordOperation]() + private let operationQueue = OperationQueue() + + private var convertedRecords = [RecordWithDatabase]() + private var recordIDsToDelete = [RecordIDWithDatabase]() + + var hasPendingOperations: Bool { + return !pendingConvertOperations.isEmpty || !recordIDsToDelete.isEmpty + } + + func prepareOperationsFor(inserted: Set, updated: Set, deleted: Set) { + prepareOperationsFor(inserted: inserted, updated: updated, deleted: convert(deleted: deleted)) + } + + func prepareOperationsFor(inserted: Set, updated: Set, deleted deletedIDs: [RecordIDWithDatabase]) { + pendingConvertOperations = convertOperations(from: inserted, changeType: .inserted) + pendingConvertOperations += convertOperations(from: updated, changeType: .updated) + + recordIDsToDelete = deletedIDs + } + + private func convertOperations(from objectSet: Set, changeType: ManagedObjectChangeType) -> [ObjectToRecordOperation] { + var operations = [ObjectToRecordOperation]() + + for object in objectSet { + // Ignore entities that doesn't have required service attributes + guard let serviceAttributeNames = object.entity.serviceAttributeNames else { continue } + + for scope in serviceAttributeNames.scopes { + do { + let recordWithSystemFields: CKRecord + + if let restoredRecord = try object.restoreRecordWithSystemFields(for: scope) { + switch changeType { + case .inserted: + // Create record with same ID but wihout token data (that record was accidently deleted from CloudKit perhaps, recordID exists in CoreData, but record doesn't exist in CloudKit + let recordID = restoredRecord.recordID + recordWithSystemFields = CKRecord(recordType: restoredRecord.recordType, recordID: recordID) + case .updated: + recordWithSystemFields = restoredRecord + } + } else { + recordWithSystemFields = try object.setRecordInformation(for: scope) + } + + var changedAttributes: [String]? + + // Save changes keys only for updated object, for inserted objects full sync will be used + if case .updated = changeType { + changedAttributes = Array(object.changedValues().keys) + + if changedAttributes?.count == 0 { + changedAttributes = object.updatedPropertyNames + } + } + + let convertOperation = ObjectToRecordOperation(scope: scope, + record: recordWithSystemFields, + changedAttributes: changedAttributes, + serviceAttributeNames: serviceAttributeNames) + + convertOperation.errorCompletionBlock = { [weak self] error in + self?.errorBlock?(error) + } + + let targetScope = targetScope(for: scope, and: object) + let cloudDatabase = database(for: targetScope) + convertOperation.conversionCompletionBlock = { [weak self] record in + guard let me = self else { return } + + let recordWithDB = RecordWithDatabase(record, cloudDatabase) + me.convertedRecords.append(recordWithDB) + } + + operations.append(convertOperation) + } catch { + errorBlock?(error) + } + } + } + + return operations + } + + private func convert(deleted objectSet: Set) -> [RecordIDWithDatabase] { + var recordIDs = [RecordIDWithDatabase]() + + for object in objectSet { + guard let serviceAttributeNames = object.entity.serviceAttributeNames else { continue } + + for scope in serviceAttributeNames.scopes { + if let restoredRecord = try? object.restoreRecordWithSystemFields(for: scope) { + let targetScope = self.targetScope(for: scope, and: object) + let database = self.database(for: targetScope) + let recordIDWithDB = RecordIDWithDatabase(restoredRecord.recordID, database) + recordIDs.append(recordIDWithDB) + } + } + } + + return recordIDs + } + + /// Add all unconfirmed operations to operation queue + /// - attention: Don't call this method from same context's `perfom`, that will cause deadlock + func processPendingOperations(in context: NSManagedObjectContext) -> (recordsToSave: [RecordWithDatabase], recordIDsToDelete: [RecordIDWithDatabase]) { + for operation in pendingConvertOperations { + operation.managedObjectContext = context + operationQueue.addOperation(operation) + } + + pendingConvertOperations = [ObjectToRecordOperation]() + + operationQueue.waitUntilAllOperationsAreFinished() + + let recordsToSave = self.convertedRecords + let recordIDsToDelete = self.recordIDsToDelete + + self.convertedRecords = [RecordWithDatabase]() + self.recordIDsToDelete = [RecordIDWithDatabase]() + + return (recordsToSave, recordIDsToDelete) + } + + /// Get appropriate database for modify operations + private func database(for scope: CKDatabase.Scope) -> CKDatabase { + return CloudCore.config.container.database(with: scope) + } + + private func targetScope(for scope: CKDatabase.Scope, and object: NSManagedObject) -> CKDatabase.Scope { + var target = scope + if scope == .private + { + if object.sharingOwnerName != CKCurrentUserDefaultName { + target = .shared + } + } + + return target + } +} diff --git a/Source/Classes/Push/ObjectToRecord/ObjectToRecordOperation.swift b/Source/Classes/Push/ObjectToRecord/ObjectToRecordOperation.swift new file mode 100644 index 00000000..0c95ed2f --- /dev/null +++ b/Source/Classes/Push/ObjectToRecord/ObjectToRecordOperation.swift @@ -0,0 +1,110 @@ +// +// ObjectToRecordOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit +import CoreData + +class ObjectToRecordOperation: Operation { + var managedObjectContext: NSManagedObjectContext? + + // Set on init + let scope: CKDatabase.Scope + let record: CKRecord + private let changedAttributes: [String]? + private let serviceAttributeNames: ServiceAttributeNames + // + + var errorCompletionBlock: ((Error) -> Void)? + var conversionCompletionBlock: ((CKRecord) -> Void)? + + init(scope: CKDatabase.Scope, record: CKRecord, changedAttributes: [String]?, serviceAttributeNames: ServiceAttributeNames) { + self.scope = scope + self.record = record + self.changedAttributes = changedAttributes + self.serviceAttributeNames = serviceAttributeNames + + super.init() + + name = "ObjectToRecordOperation" + qualityOfService = .userInteractive + } + + override func main() { + if self.isCancelled { return } + guard let context = managedObjectContext else { + let error = CloudCoreError.coreData("CloudCore framework error") + errorCompletionBlock?(error) + return + } + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + context.performAndWait { + do { + try self.fillRecordWithData() + try context.save() + self.conversionCompletionBlock?(self.record) + } catch { + self.errorCompletionBlock?(error) + } + } + } + + private func fillRecordWithData() throws { + guard let managedObject = try fetchObject(for: record) else { + throw CloudCoreError.coreData("Unable to find managed object for record: \(record)") + } + + let changedValues = managedObject.committedValues(forKeys: changedAttributes) + + for (attributeName, value) in changedValues { + if serviceAttributeNames.isMaskedUpload(attributeName) { continue } + + if let attribute = CoreDataAttribute(value: value, attributeName: attributeName, entity: managedObject.entity) { + let recordValue = try attribute.makeRecordValue() + if #available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) { + if attribute.description.allowsCloudEncryption { + record.encryptedValues[attributeName] = (recordValue as! __CKRecordObjCValue) + } else { + record.setValue(recordValue, forKey: attributeName) + } + } else { + record.setValue(recordValue, forKey: attributeName) + } + } else if let relationship = CoreDataRelationship(scope: scope, value: value, relationshipName: attributeName, entity: managedObject.entity) { + let references = try relationship.makeRecordValue() + record.setValue(references, forKey: attributeName) + + if let parentRef = references as? CKRecord.Reference, + parentRef.recordID.zoneID.ownerName == managedObject.sharingOwnerName, + let parentAttributeName = managedObject.parentAttributeName, + parentAttributeName == attributeName + { + record.setParent(parentRef.recordID) + } + } + } + } + + private func fetchObject(for record: CKRecord) throws -> NSManagedObject? { + let entityName = record.recordType + + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: serviceAttributeNames.recordName + " == %@", record.recordID.recordName) + + return try managedObjectContext?.fetch(fetchRequest).first as? NSManagedObject + } +} diff --git a/Source/Classes/Save/CloudSaveOperationQueue.swift b/Source/Classes/Push/PushOperationQueue.swift similarity index 71% rename from Source/Classes/Save/CloudSaveOperationQueue.swift rename to Source/Classes/Push/PushOperationQueue.swift index 583cde4d..8e83ac9c 100644 --- a/Source/Classes/Save/CloudSaveOperationQueue.swift +++ b/Source/Classes/Push/PushOperationQueue.swift @@ -9,7 +9,7 @@ import CloudKit import CoreData -class CloudSaveOperationQueue: OperationQueue { +class PushOperationQueue: OperationQueue { var errorBlock: ErrorBlock? /// Modify CloudKit database, operations will be created and added to operation queue. @@ -44,12 +44,15 @@ class CloudSaveOperationQueue: OperationQueue { } } - private func addOperation(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecordID], database: CKDatabase) { + private func addOperation(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], database: CKDatabase) { // Modify CKRecord Operation - let modifyOperation = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) - modifyOperation.savePolicy = .changedKeys - - modifyOperation.perRecordCompletionBlock = { record, error in + let modifyRecords = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) + modifyRecords.database = database + modifyRecords.savePolicy = .changedKeys + modifyRecords.qualityOfService = .userInitiated + modifyRecords.isAtomic = true + + modifyRecords.perRecordCompletionBlock = { record, error in if let error = error { self.errorBlock?(error) } else { @@ -57,31 +60,34 @@ class CloudSaveOperationQueue: OperationQueue { } } - modifyOperation.modifyRecordsCompletionBlock = { _, _, error in + modifyRecords.modifyRecordsCompletionBlock = { _, _, error in if let error = error { self.errorBlock?(error) } } - - modifyOperation.database = database - - self.addOperation(modifyOperation) + + let finish = BlockOperation { } + finish.addDependency(modifyRecords) + database.add(modifyRecords) + self.addOperation(finish) } /// Remove locally cached assets prepared for uploading at CloudKit private func removeCachedAssets(for record: CKRecord) { for key in record.allKeys() { guard let asset = record.value(forKey: key) as? CKAsset else { continue } - try? FileManager.default.removeItem(at: asset.fileURL) + if let url = asset.fileURL { + try? FileManager.default.removeItem(at: url) + } } } - + } fileprivate class DatabaseModifyDataSource { let database: CKDatabase var save = [CKRecord]() - var delete = [CKRecordID]() + var delete = [CKRecord.ID]() init(database: CKDatabase) { self.database = database diff --git a/Source/Classes/Save/CoreDataListener.swift b/Source/Classes/Save/CoreDataListener.swift deleted file mode 100644 index 7410706f..00000000 --- a/Source/Classes/Save/CoreDataListener.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// CoreDataChangesListener.swift -// CloudCore -// -// Created by Vasily Ulianov on 02.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import Foundation -import CoreData -import CloudKit - -/// Class responsible for taking action on Core Data save notifications -class CoreDataListener { - var container: NSPersistentContainer - - let converter = ObjectToRecordConverter() - let cloudSaveOperationQueue = CloudSaveOperationQueue() - - let cloudContextName = "CloudCoreSync" - - // Used for errors delegation - weak var delegate: CloudCoreDelegate? - - public init(container: NSPersistentContainer) { - self.container = container - converter.errorBlock = { [weak self] in - self?.delegate?.error(error: $0, module: .some(.saveToCloud)) - } - } - - /// Observe Core Data willSave and didSave notifications - func observe() { - NotificationCenter.default.addObserver(self, selector: #selector(self.willSave(notification:)), name: .NSManagedObjectContextWillSave, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(self.didSave(notification:)), name: .NSManagedObjectContextDidSave, object: nil) - } - - /// Remove Core Data observers - func stopObserving() { - NotificationCenter.default.removeObserver(self) - } - - deinit { - stopObserving() - } - - @objc private func willSave(notification: Notification) { - guard let context = notification.object as? NSManagedObjectContext else { return } - - // Ignore saves that are generated by FetchAndSaveController - if context.name == CloudCore.config.contextName { return } - - // Upload only for changes in root context that will be saved to persistentStore - if context.parent != nil { return } - - converter.setUnconfirmedOperations(inserted: context.insertedObjects, - updated: context.updatedObjects, - deleted: context.deletedObjects) - } - - @objc private func didSave(notification: Notification) { - guard let context = notification.object as? NSManagedObjectContext else { return } - if context.name == CloudCore.config.contextName { return } - if context.parent != nil { return } - - if converter.notConfirmedConvertOperations.isEmpty && converter.recordIDsToDelete.isEmpty { return } - - DispatchQueue.global(qos: .utility).async { [weak self] in - guard let listener = self else { return } - CloudCore.delegate?.willSyncToCloud() - - let backgroundContext = listener.container.newBackgroundContext() - backgroundContext.name = listener.cloudContextName - - let records = listener.converter.confirmConvertOperationsAndWait(in: backgroundContext) - listener.cloudSaveOperationQueue.errorBlock = { listener.handle(error: $0, parentContext: backgroundContext) } - listener.cloudSaveOperationQueue.addOperations(recordsToSave: records.recordsToSave, recordIDsToDelete: records.recordIDsToDelete) - listener.cloudSaveOperationQueue.waitUntilAllOperationsAreFinished() - - do { - if backgroundContext.hasChanges { - try backgroundContext.save() - } - } catch { - listener.delegate?.error(error: error, module: .some(.saveToCloud)) - } - - CloudCore.delegate?.didSyncToCloud() - } - } - - private func handle(error: Error, parentContext: NSManagedObjectContext) { - guard let cloudError = error as? CKError else { - delegate?.error(error: error, module: .some(.saveToCloud)) - return - } - - switch cloudError.code { - // Zone was accidentally deleted (NOT PURGED), we need to reupload all data accroding Apple Guidelines - case .zoneNotFound: - cloudSaveOperationQueue.cancelAllOperations() - - // Create CloudCore Zone - let createZoneOperation = CreateCloudCoreZoneOperation() - createZoneOperation.errorBlock = { - self.delegate?.error(error: $0, module: .some(.saveToCloud)) - self.cloudSaveOperationQueue.cancelAllOperations() - } - - // Subscribe operation - #if !os(watchOS) - let subscribeOperation = SubscribeOperation() - subscribeOperation.errorBlock = { self.delegate?.error(error: $0, module: .some(.saveToCloud)) } - subscribeOperation.addDependency(createZoneOperation) - cloudSaveOperationQueue.addOperation(subscribeOperation) - #endif - - // Upload all local data - let uploadOperation = UploadAllLocalDataOperation(parentContext: parentContext, managedObjectModel: container.managedObjectModel) - uploadOperation.errorBlock = { self.delegate?.error(error: $0, module: .some(.saveToCloud)) } - - cloudSaveOperationQueue.addOperations([createZoneOperation, uploadOperation], waitUntilFinished: true) - case .operationCancelled: return - default: delegate?.error(error: cloudError, module: .some(.saveToCloud)) - } - } - -} diff --git a/Source/Classes/Save/ObjectToRecord/ObjectToRecordConverter.swift b/Source/Classes/Save/ObjectToRecord/ObjectToRecordConverter.swift deleted file mode 100644 index aa3d9bf8..00000000 --- a/Source/Classes/Save/ObjectToRecord/ObjectToRecordConverter.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// ObjectToRecordConverter.swift -// CloudCore -// -// Created by Vasily Ulianov on 09.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CoreData -import CloudKit - -class ObjectToRecordConverter { - enum ManagedObjectChangeType { - case inserted, updated - } - - var errorBlock: ErrorBlock? - - private(set) var notConfirmedConvertOperations = [ObjectToRecordOperation]() - private let operationQueue = OperationQueue() - - private var convertedRecords = [RecordWithDatabase]() - private(set) var recordIDsToDelete = [RecordIDWithDatabase]() - - func setUnconfirmedOperations(inserted: Set, updated: Set, deleted: Set) { - self.notConfirmedConvertOperations = self.convertOperations(from: inserted, changeType: .inserted) - self.notConfirmedConvertOperations += self.convertOperations(from: updated, changeType: .updated) - - self.recordIDsToDelete = convert(deleted: deleted) - } - - private func convertOperations(from objectSet: Set, changeType: ManagedObjectChangeType) -> [ObjectToRecordOperation] { - var operations = [ObjectToRecordOperation]() - - for object in objectSet { - // Ignore entities that doesn't have required service attributes - guard let serviceAttributeNames = object.entity.serviceAttributeNames else { continue } - - do { - let recordWithSystemFields: CKRecord - - if let restoredRecord = try object.restoreRecordWithSystemFields() { - switch changeType { - case .inserted: - // Create record with same ID but wihout token data (that record was accidently deleted from CloudKit perhaps, recordID exists in CoreData, but record doesn't exist in CloudKit - let recordID = restoredRecord.recordID - recordWithSystemFields = CKRecord(recordType: restoredRecord.recordType, recordID: recordID) - case .updated: - recordWithSystemFields = restoredRecord - } - } else { - recordWithSystemFields = try object.setRecordInformation() - } - - var changedAttributes: [String]? - - // Save changes keys only for updated object, for inserted objects full sync will be used - if case .updated = changeType { changedAttributes = Array(object.changedValues().keys) } - - let convertOperation = ObjectToRecordOperation(record: recordWithSystemFields, - changedAttributes: changedAttributes, - serviceAttributeNames: serviceAttributeNames) - - convertOperation.errorCompletionBlock = { [weak self] error in - self?.errorBlock?(error) - } - - convertOperation.conversionCompletionBlock = { [weak self] record in - guard let me = self else { return } - - let cloudDatabase = me.database(for: record.recordID, serviceAttributes: serviceAttributeNames) - let recordWithDB = RecordWithDatabase(record, cloudDatabase) - me.convertedRecords.append(recordWithDB) - } - - operations.append(convertOperation) - } catch { - errorBlock?(error) - } - } - - return operations - } - - private func convert(deleted objectSet: Set) -> [RecordIDWithDatabase] { - var recordIDs = [RecordIDWithDatabase]() - - for object in objectSet { - if let triedRestoredRecord = try? object.restoreRecordWithSystemFields(), - let restoredRecord = triedRestoredRecord, - let serviceAttributeNames = object.entity.serviceAttributeNames { - let database = self.database(for: restoredRecord.recordID, serviceAttributes: serviceAttributeNames) - let recordIDWithDB = RecordIDWithDatabase(restoredRecord.recordID, database) - recordIDs.append(recordIDWithDB) - } - } - - return recordIDs - } - - /// Add all uncofirmed operations to operation queue - /// - attention: Don't call this method from same context's `perfom`, that will cause deadlock - func confirmConvertOperationsAndWait(in context: NSManagedObjectContext) -> (recordsToSave: [RecordWithDatabase], recordIDsToDelete: [RecordIDWithDatabase]) { - for operation in notConfirmedConvertOperations { - operation.parentContext = context - operationQueue.addOperation(operation) - } - - notConfirmedConvertOperations = [ObjectToRecordOperation]() - operationQueue.waitUntilAllOperationsAreFinished() - - let recordsToSave = self.convertedRecords - let recordIDsToDelete = self.recordIDsToDelete - - self.convertedRecords = [RecordWithDatabase]() - self.recordIDsToDelete = [RecordIDWithDatabase]() - - return (recordsToSave, recordIDsToDelete) - } - - /// Get appropriate database for modify operations - private func database(for recordID: CKRecordID, serviceAttributes: ServiceAttributeNames) -> CKDatabase { - let container = CloudCore.config.container - - if serviceAttributes.isPublic { return container.publicCloudDatabase } - - let ownerName = recordID.zoneID.ownerName - - if ownerName == CKCurrentUserDefaultName { - return container.privateCloudDatabase - } else { - return container.sharedCloudDatabase - } - } -} diff --git a/Source/Classes/Save/ObjectToRecord/ObjectToRecordOperation.swift b/Source/Classes/Save/ObjectToRecord/ObjectToRecordOperation.swift deleted file mode 100644 index 917200bd..00000000 --- a/Source/Classes/Save/ObjectToRecord/ObjectToRecordOperation.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// ObjectToRecordOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 09.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CloudKit -import CoreData - -class ObjectToRecordOperation: Operation { - /// Need to set before starting operation, child context from it will be created - var parentContext: NSManagedObjectContext? - - // Set on init - let record: CKRecord - private let changedAttributes: [String]? - private let serviceAttributeNames: ServiceAttributeNames - // - - var errorCompletionBlock: ((Error) -> Void)? - var conversionCompletionBlock: ((CKRecord) -> Void)? - - init(record: CKRecord, changedAttributes: [String]?, serviceAttributeNames: ServiceAttributeNames) { - self.record = record - self.changedAttributes = changedAttributes - self.serviceAttributeNames = serviceAttributeNames - - super.init() - self.name = "ObjectToRecordOperation" - } - - override func main() { - if self.isCancelled { return } - guard let parentContext = parentContext else { - let error = CloudCoreError.coreData("CloudCore framework error") - errorCompletionBlock?(error) - return - } - - let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - childContext.parent = parentContext - - do { - try self.fillRecordWithData(using: childContext) - try childContext.save() - self.conversionCompletionBlock?(self.record) - } catch { - self.errorCompletionBlock?(error) - } - } - - private func fillRecordWithData(using context: NSManagedObjectContext) throws { - guard let managedObject = try fetchObject(for: record, using: context) else { - throw CloudCoreError.coreData("Unable to find managed object for record: \(record)") - } - - let changedValues = managedObject.committedValues(forKeys: changedAttributes) - - for (attributeName, value) in changedValues { - if attributeName == serviceAttributeNames.recordData || attributeName == serviceAttributeNames.recordID { continue } - - if let attribute = CoreDataAttribute(value: value, attributeName: attributeName, entity: managedObject.entity) { - let recordValue = try attribute.makeRecordValue() - record.setValue(recordValue, forKey: attributeName) - } else if let relationship = CoreDataRelationship(value: value, relationshipName: attributeName, entity: managedObject.entity) { - let references = try relationship.makeRecordValue() - record.setValue(references, forKey: attributeName) - } - } - } - - private func fetchObject(for record: CKRecord, using context: NSManagedObjectContext) throws -> NSManagedObject? { - let entityName = record.recordType - - let fetchRequest = NSFetchRequest(entityName: entityName) - fetchRequest.predicate = NSPredicate(format: serviceAttributeNames.recordID + " == %@", record.recordID.encodedString) - - return try context.fetch(fetchRequest).first as? NSManagedObject - } -} diff --git a/Source/Classes/Setup Operation/SetupOperation.swift b/Source/Classes/Setup Operation/SetupOperation.swift deleted file mode 100644 index ceaddc8b..00000000 --- a/Source/Classes/Setup Operation/SetupOperation.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// SetupOperation.swift -// CloudCore-iOS -// -// Created by Vasily Ulianov on 13/12/2017. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import Foundation -import CoreData - -/** - Performs several setup operations: - - 1. Create CloudCore Zone. - 2. Subscribe to that zone. - 3. Upload all local data to cloud. -*/ -class SetupOperation: Operation { - - var errorBlock: ErrorBlock? - let container: NSPersistentContainer - let parentContext: NSManagedObjectContext? - - /// - Parameters: - /// - container: persistent container to get managedObject model from - /// - parentContext: context where changed data will be save (recordID's). If it is `nil`, new context will be created from `container` and saved - init(container: NSPersistentContainer, parentContext: NSManagedObjectContext?) { - self.container = container - self.parentContext = parentContext - } - - private let queue = OperationQueue() - - override func main() { - super.main() - - let childContext: NSManagedObjectContext - - if let parentContext = self.parentContext { - childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - childContext.parent = parentContext - } else { - childContext = container.newBackgroundContext() - } - - // Create CloudCore Zone - let createZoneOperation = CreateCloudCoreZoneOperation() - createZoneOperation.errorBlock = { - self.errorBlock?($0) - self.queue.cancelAllOperations() - } - - // Subscribe operation - #if !os(watchOS) - let subscribeOperation = SubscribeOperation() - subscribeOperation.errorBlock = errorBlock - subscribeOperation.addDependency(createZoneOperation) - queue.addOperation(subscribeOperation) - #endif - - // Upload all local data - let uploadOperation = UploadAllLocalDataOperation(parentContext: childContext, managedObjectModel: container.managedObjectModel) - uploadOperation.errorBlock = errorBlock - - #if !os(watchOS) - uploadOperation.addDependency(subscribeOperation) - #endif - - queue.addOperations([createZoneOperation, uploadOperation], waitUntilFinished: true) - - if self.parentContext == nil { - do { - // It's safe to save because we instatinated that context in current thread - try childContext.save() - } catch { - errorBlock?(error) - } - } - } - -} diff --git a/Source/Classes/Setup Operation/SubscribeOperation.swift b/Source/Classes/Setup Operation/SubscribeOperation.swift deleted file mode 100644 index e7b01277..00000000 --- a/Source/Classes/Setup Operation/SubscribeOperation.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// SubscribeOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 12/12/2017. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import Foundation -import CloudKit - -#if !os(watchOS) -@available(watchOS, unavailable) -class SubscribeOperation: AsynchronousOperation { - - var errorBlock: ErrorBlock? - - private let queue = OperationQueue() - - override func main() { - super.main() - - let container = CloudCore.config.container - - // Subscribe operation - let subcribeToPrivate = self.makeRecordZoneSubscriptionOperation(for: container.privateCloudDatabase, id: CloudCore.config.subscriptionIDForPrivateDB) - - // Fetch subscriptions and cancel subscription operation if subscription is already exists - let fetchPrivateSubscriptions = makeFetchSubscriptionOperation(for: container.privateCloudDatabase, - searchForSubscriptionID: CloudCore.config.subscriptionIDForPrivateDB, - operationToCancelIfSubcriptionExists: subcribeToPrivate) - - subcribeToPrivate.addDependency(fetchPrivateSubscriptions) - - // Finish operation - let finishOperation = BlockOperation { - self.state = .finished - } - finishOperation.addDependency(subcribeToPrivate) - finishOperation.addDependency(fetchPrivateSubscriptions) - - queue.addOperations([subcribeToPrivate, fetchPrivateSubscriptions, finishOperation], waitUntilFinished: false) - } - - private func makeRecordZoneSubscriptionOperation(for database: CKDatabase, id: String) -> CKModifySubscriptionsOperation { - let notificationInfo = CKNotificationInfo() - notificationInfo.shouldSendContentAvailable = true - - let subscription = CKRecordZoneSubscription(zoneID: CloudCore.config.zoneID, subscriptionID: id) - subscription.notificationInfo = notificationInfo - - let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) - operation.modifySubscriptionsCompletionBlock = { - if let error = $2 { - // Cancellation is not an error - if case CKError.operationCancelled = error { return } - - self.errorBlock?(error) - } - } - - operation.database = database - - return operation - } - - private func makeFetchSubscriptionOperation(for database: CKDatabase, searchForSubscriptionID subscriptionID: String, operationToCancelIfSubcriptionExists operationToCancel: CKModifySubscriptionsOperation) -> CKFetchSubscriptionsOperation { - let fetchSubscriptions = CKFetchSubscriptionsOperation(subscriptionIDs: [subscriptionID]) - fetchSubscriptions.database = database - fetchSubscriptions.fetchSubscriptionCompletionBlock = { subscriptions, error in - // If no errors = subscription is found and we don't need to subscribe again - if error == nil { - operationToCancel.cancel() - } - } - - return fetchSubscriptions - } - -} -#endif diff --git a/Source/Classes/Setup Operation/UploadAllLocalDataOperation.swift b/Source/Classes/Setup Operation/UploadAllLocalDataOperation.swift deleted file mode 100644 index fe45d28b..00000000 --- a/Source/Classes/Setup Operation/UploadAllLocalDataOperation.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// UploadAllLocalDataOperation.swift -// CloudCore -// -// Created by Vasily Ulianov on 12/12/2017. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import Foundation -import CoreData - -class UploadAllLocalDataOperation: Operation { - - let managedObjectModel: NSManagedObjectModel - let parentContext: NSManagedObjectContext - - var errorBlock: ErrorBlock? { - didSet { - converter.errorBlock = errorBlock - cloudSaveOperationQueue.errorBlock = errorBlock - } - } - - private let converter = ObjectToRecordConverter() - private let cloudSaveOperationQueue = CloudSaveOperationQueue() - - init(parentContext: NSManagedObjectContext, managedObjectModel: NSManagedObjectModel) { - self.parentContext = parentContext - self.managedObjectModel = managedObjectModel - } - - override func main() { - super.main() - - CloudCore.delegate?.willSyncToCloud() - defer { - CloudCore.delegate?.didSyncToCloud() - } - - let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - childContext.parent = parentContext - - var allManagedObjects = Set() - for entity in managedObjectModel.cloudCoreEnabledEntities { - guard let entityName = entity.name else { continue } - let fetchRequest = NSFetchRequest(entityName: entityName) - - do { - guard let fetchedObjects = try childContext.fetch(fetchRequest) as? [NSManagedObject] else { - continue - } - - allManagedObjects.formUnion(fetchedObjects) - } catch { - errorBlock?(error) - } - } - - converter.setUnconfirmedOperations(inserted: allManagedObjects, updated: Set(), deleted: Set()) - let recordsToSave = converter.confirmConvertOperationsAndWait(in: childContext).recordsToSave - cloudSaveOperationQueue.addOperations(recordsToSave: recordsToSave, recordIDsToDelete: [RecordIDWithDatabase]()) - cloudSaveOperationQueue.waitUntilAllOperationsAreFinished() - - do { - try childContext.save() - } catch { - errorBlock?(error) - } - } - - override func cancel() { - cloudSaveOperationQueue.cancelAllOperations() - - super.cancel() - } - -} diff --git a/Source/Classes/Setup Operation/CreateCloudCoreZoneOperation.swift b/Source/Classes/Setup/CreateCloudCoreZoneOperation.swift similarity index 79% rename from Source/Classes/Setup Operation/CreateCloudCoreZoneOperation.swift rename to Source/Classes/Setup/CreateCloudCoreZoneOperation.swift index 7cef4c4c..00285a86 100644 --- a/Source/Classes/Setup Operation/CreateCloudCoreZoneOperation.swift +++ b/Source/Classes/Setup/CreateCloudCoreZoneOperation.swift @@ -14,11 +14,19 @@ class CreateCloudCoreZoneOperation: AsynchronousOperation { var errorBlock: ErrorBlock? private var createZoneOperation: CKModifyRecordZonesOperation? + public override init() { + super.init() + + name = "CreateCloudCoreZoneOperation" + qualityOfService = .userInitiated + } + override func main() { super.main() - let cloudCoreZone = CKRecordZone(zoneName: CloudCore.config.zoneID.zoneName) + let cloudCoreZone = CKRecordZone(zoneName: CloudCore.config.zoneName) let recordZoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: [cloudCoreZone], recordZoneIDsToDelete: nil) + recordZoneOperation.qualityOfService = .userInitiated recordZoneOperation.modifyRecordZonesCompletionBlock = { if let error = $2 { self.errorBlock?(error) diff --git a/Source/Classes/Setup/DeleteCloudCoreZoneOperation.swift b/Source/Classes/Setup/DeleteCloudCoreZoneOperation.swift new file mode 100644 index 00000000..2962c8e9 --- /dev/null +++ b/Source/Classes/Setup/DeleteCloudCoreZoneOperation.swift @@ -0,0 +1,41 @@ +// +// DeleteCloudCoreZoneOperation.swift +// CloudCore +// +// Created by deeje cooley on 4/3/22. +// + +import Foundation +import CloudKit + +class DeleteCloudCoreZoneOperation: AsynchronousOperation { + + var errorBlock: ErrorBlock? + private var deleteZoneOperation: CKModifyRecordZonesOperation? + + public override init() { + super.init() + + name = "CreateCloudCoreZoneOperation" + qualityOfService = .userInitiated + } + + override func main() { + super.main() + + let cloudCoreZone = CKRecordZone(zoneName: CloudCore.config.zoneName) + let recordZoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: nil, recordZoneIDsToDelete: [cloudCoreZone.zoneID]) + recordZoneOperation.qualityOfService = .userInitiated + recordZoneOperation.modifyRecordZonesCompletionBlock = { + if let error = $2 { + self.errorBlock?(error) + } + + self.state = .finished + } + + CloudCore.config.container.privateCloudDatabase.add(recordZoneOperation) + self.deleteZoneOperation = recordZoneOperation + } + +} diff --git a/Source/Classes/Setup/PushAllLocalDataOperation.swift b/Source/Classes/Setup/PushAllLocalDataOperation.swift new file mode 100644 index 00000000..36600624 --- /dev/null +++ b/Source/Classes/Setup/PushAllLocalDataOperation.swift @@ -0,0 +1,94 @@ +// +// UploadAllLocalDataOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 12/12/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CoreData + +class PushAllLocalDataOperation: Operation { + + let managedObjectModel: NSManagedObjectModel + let parentContext: NSManagedObjectContext + + var errorBlock: ErrorBlock? { + didSet { + converter.errorBlock = errorBlock + pushOperationQueue.errorBlock = errorBlock + } + } + + private let converter = ObjectToRecordConverter() + private let pushOperationQueue = PushOperationQueue() + + init(parentContext: NSManagedObjectContext, managedObjectModel: NSManagedObjectModel) { + self.parentContext = parentContext + self.managedObjectModel = managedObjectModel + + super.init() + + name = "PushAllLocalDataOperation" + qualityOfService = .userInitiated + } + + override func main() { + super.main() + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + CloudCore.delegate?.willSyncToCloud() + defer { + CloudCore.delegate?.didSyncToCloud() + } + + let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + childContext.performAndWait { + childContext.parent = parentContext + + var allManagedObjects = Set() + for entity in managedObjectModel.cloudCoreEnabledEntities { + guard let entityName = entity.name else { continue } + let fetchRequest = NSFetchRequest(entityName: entityName) + + do { + guard let fetchedObjects = try childContext.fetch(fetchRequest) as? [NSManagedObject] else { + continue + } + + allManagedObjects.formUnion(fetchedObjects) + } catch { + errorBlock?(error) + } + } + + converter.prepareOperationsFor(inserted: allManagedObjects, updated: Set(), deleted: Set()) + let recordsToSave = converter.processPendingOperations(in: childContext).recordsToSave + pushOperationQueue.addOperations(recordsToSave: recordsToSave, recordIDsToDelete: [RecordIDWithDatabase]()) + pushOperationQueue.waitUntilAllOperationsAreFinished() + + do { + try childContext.save() + } catch { + errorBlock?(error) + } + } + } + + override func cancel() { + pushOperationQueue.cancelAllOperations() + + super.cancel() + } + +} diff --git a/Source/Classes/Setup/SetupOperation.swift b/Source/Classes/Setup/SetupOperation.swift new file mode 100644 index 00000000..03e7b477 --- /dev/null +++ b/Source/Classes/Setup/SetupOperation.swift @@ -0,0 +1,92 @@ +// +// SetupOperation.swift +// CloudCore-iOS +// +// Created by Vasily Ulianov on 13/12/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CoreData + +/** + Performs several setup operations: + + 1. Create CloudCore Zone. + 2. Subscribe to that zone. + 3. Upload all local data to cloud. +*/ +class SetupOperation: Operation { + + var errorBlock: ErrorBlock? + let container: NSPersistentContainer + let uploadAllData: Bool + + /// - Parameters: + /// - container: persistent container to get managedObject model from + /// - parentContext: context where changed data will be save (recordID's). If it is `nil`, new context will be created from `container` and saved + init(container: NSPersistentContainer, uploadAllData: Bool) { + self.container = container + self.uploadAllData = uploadAllData + + super.init() + + name = "SetupOperation" + qualityOfService = .userInitiated + } + + private let queue = OperationQueue() + + override func main() { + super.main() + + #if TARGET_OS_IOS + let app = UIApplication.shared + var backgroundTaskID = app.beginBackgroundTask(withName: name) { + app.endBackgroundTask(backgroundTaskID!) + } + defer { + app.endBackgroundTask(backgroundTaskID!) + } + #endif + + let childContext = container.newBackgroundContext() + var operations: [Operation] = [] + + // Create CloudCore Zone + let createZoneOperation = CreateCloudCoreZoneOperation() + createZoneOperation.errorBlock = { + self.errorBlock?($0) + self.queue.cancelAllOperations() + } + operations.append(createZoneOperation) + + // Subscribe operation + let subscribeOperation = SubscribeOperation() + subscribeOperation.errorBlock = errorBlock + subscribeOperation.addDependency(createZoneOperation) + operations.append(subscribeOperation) + + if uploadAllData { + // Upload all local data + let uploadOperation = PushAllLocalDataOperation(parentContext: childContext, managedObjectModel: container.managedObjectModel) + uploadOperation.errorBlock = errorBlock + + uploadOperation.addDependency(subscribeOperation) + operations.append(uploadOperation) + } + + queue.maxConcurrentOperationCount = 1 + queue.addOperations(operations, waitUntilFinished: true) + + childContext.performAndWait { + do { + // It's safe to save because we instatinated that context in current thread + try childContext.save() + } catch { + errorBlock?(error) + } + } + } + +} diff --git a/Source/Classes/Setup/SubscribeOperation.swift b/Source/Classes/Setup/SubscribeOperation.swift new file mode 100644 index 00000000..ef625fbf --- /dev/null +++ b/Source/Classes/Setup/SubscribeOperation.swift @@ -0,0 +1,97 @@ +// +// SubscribeOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 12/12/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CloudKit + +class SubscribeOperation: AsynchronousOperation { + + var errorBlock: ErrorBlock? + + private let queue = OperationQueue() + + public override init() { + super.init() + + name = "SubscribeOperation" + qualityOfService = .userInitiated + } + + override func main() { + super.main() + + let container = CloudCore.config.container + + let subcribeToPrivate = self.makeRecordZoneSubscriptionOperation(for: container.privateCloudDatabase, id: CloudCore.config.subscriptionIDForPrivateDB) + let fetchPrivateSubscription = makeFetchSubscriptionOperation(for: container.privateCloudDatabase, + searchForSubscriptionID: CloudCore.config.subscriptionIDForPrivateDB, + operationToCancelIfSubcriptionExists: subcribeToPrivate) + subcribeToPrivate.addDependency(fetchPrivateSubscription) + + let subscribeToShared = self.makeRecordZoneSubscriptionOperation(for: container.sharedCloudDatabase, id: CloudCore.config.subscriptionIDForSharedDB) + let fetchSharedSubscription = makeFetchSubscriptionOperation(for: container.sharedCloudDatabase, + searchForSubscriptionID: CloudCore.config.subscriptionIDForSharedDB, + operationToCancelIfSubcriptionExists: subscribeToShared) + subscribeToShared.addDependency(fetchSharedSubscription) + + // Finish operation + let finishOperation = BlockOperation { + self.state = .finished + } + finishOperation.addDependency(subcribeToPrivate) + finishOperation.addDependency(fetchPrivateSubscription) + finishOperation.addDependency(subscribeToShared) + finishOperation.addDependency(fetchSharedSubscription) + + subcribeToPrivate.database?.add(subcribeToPrivate) + fetchPrivateSubscription.database?.add(fetchPrivateSubscription) + subscribeToShared.database?.add(subscribeToShared) + fetchSharedSubscription.database?.add(fetchSharedSubscription) + + queue.addOperation(finishOperation) + } + + private func makeRecordZoneSubscriptionOperation(for database: CKDatabase, id: String) -> CKModifySubscriptionsOperation { + let notificationInfo = CKSubscription.NotificationInfo() + notificationInfo.shouldSendContentAvailable = true + + let subscription = (database == CloudCore.config.container.sharedCloudDatabase) ? CKDatabaseSubscription(subscriptionID: id) : + CKRecordZoneSubscription(zoneID: CloudCore.config.privateZoneID(), subscriptionID: id) + subscription.notificationInfo = notificationInfo + + let modifySubscriptions = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) + modifySubscriptions.database = database + modifySubscriptions.modifySubscriptionsCompletionBlock = { + if let error = $2 { + // Cancellation is not an error + if case CKError.operationCancelled = error { return } + + self.errorBlock?(error) + } + } + + modifySubscriptions.qualityOfService = .userInitiated + + return modifySubscriptions + } + + private func makeFetchSubscriptionOperation(for database: CKDatabase, searchForSubscriptionID subscriptionID: String, operationToCancelIfSubcriptionExists operationToCancel: CKModifySubscriptionsOperation) -> CKFetchSubscriptionsOperation { + let fetchSubscriptions = CKFetchSubscriptionsOperation(subscriptionIDs: [subscriptionID]) + fetchSubscriptions.database = database + fetchSubscriptions.fetchSubscriptionCompletionBlock = { subscriptions, error in + // If no errors = subscription is found and we don't need to subscribe again + if error == nil { + operationToCancel.cancel() + } + } + fetchSubscriptions.qualityOfService = .userInitiated + + return fetchSubscriptions + } + +} diff --git a/Source/Classes/Sharing/CloudCoreSharing.swift b/Source/Classes/Sharing/CloudCoreSharing.swift new file mode 100644 index 00000000..c28a9db0 --- /dev/null +++ b/Source/Classes/Sharing/CloudCoreSharing.swift @@ -0,0 +1,141 @@ +// +// CloudCoreSharing.swift +// CloudCore +// +// Created by deeje cooley on 5/25/21. +// + +import CoreData +import CloudKit + +public typealias FetchedEditablePermissionsCompletionBlock = (_ canEdit: Bool) -> Void +public typealias StopSharingCompletionBlock = (_ didStop: Bool) -> Void + +public protocol CloudCoreSharing: CloudKitSharing, CloudCoreType { + + var isOwnedByCurrentUser: Bool { get } + var isShared: Bool { get } + var shareRecordData: Data? { get set } + + func fetchExistingShareRecord(completion: @escaping ((CKShare?, Error?) -> Void)) + func fetchShareRecord(completion: @escaping ((CKShare?, Error?) -> Void)) + func fetchEditablePermissions(completion: @escaping FetchedEditablePermissionsCompletionBlock) + func setShareRecord(share: CKShare?, in persistentContainer: NSPersistentContainer) + func stopSharing(in persistentContainer: NSPersistentContainer, completion: @escaping StopSharingCompletionBlock) + +} + +extension CloudCoreSharing { + + public var isOwnedByCurrentUser: Bool { + get { + return ownerName == CKCurrentUserDefaultName + } + } + + public var isShared: Bool { + get { + return shareRecordData != nil + } + } + + func shareDatabaseAndRecordID(from shareData: Data) -> (CKDatabase, CKRecord.ID) { + let shareForName = CKShare(archivedData: shareData)! + let database: CKDatabase + let shareID: CKRecord.ID + + if isOwnedByCurrentUser { + database = CloudCore.config.container.privateCloudDatabase + + shareID = shareForName.recordID + } else { + database = CloudCore.config.container.sharedCloudDatabase + + let zoneID = CKRecordZone.ID(zoneName: CloudCore.config.zoneName, ownerName: ownerName!) + shareID = CKRecord.ID(recordName: shareForName.recordID.recordName, zoneID: zoneID) + } + + return (database, shareID) + } + + public func fetchExistingShareRecord(completion: @escaping ((CKShare?, Error?) -> Void)) { + managedObjectContext?.refresh(self, mergeChanges: true) + + if let shareData = shareRecordData { + let (database, shareID) = shareDatabaseAndRecordID(from: shareData) + + database.fetch(withRecordID: shareID) { record, error in + completion(record as? CKShare, error) + } + } else { + completion(nil, nil) + } + } + + public func fetchShareRecord(completion: @escaping ((CKShare?, Error?) -> Void)) { + let aRecord = try! self.restoreRecordWithSystemFields(for: .private)! + let title = sharingTitle as CKRecordValue? + let type = sharingType as CKRecordValue? + + fetchExistingShareRecord { share, error in + if let share = share { + completion(share, nil) + } else { + let newShare = CKShare(rootRecord: aRecord) + newShare[CKShare.SystemFieldKey.title] = title + newShare[CKShare.SystemFieldKey.shareType] = type + + completion(newShare, nil) + } + } + } + + public func fetchEditablePermissions(completion: @escaping FetchedEditablePermissionsCompletionBlock) { + if isOwnedByCurrentUser { + completion(true) + } else { + fetchExistingShareRecord { record, error in + var canEdit = false + + if let fetchedShare = record { + for aParticipant in fetchedShare.participants { + if aParticipant.userIdentity.userRecordID?.recordName == CKCurrentUserDefaultName { + canEdit = aParticipant.permission == .readWrite + break + } + } + } + + DispatchQueue.main.async { + completion(canEdit) + } + } + } + } + + public func setShareRecord(share: CKShare?, in persistentContainer: NSPersistentContainer) { + persistentContainer.performBackgroundPushTask { moc in + if let updatedObject = try? moc.existingObject(with: self.objectID) as? CloudCoreSharing { + updatedObject.shareRecordData = share?.encdodedSystemFields + try? moc.save() + } + } + } + + public func stopSharing(in persistentContainer: NSPersistentContainer, completion: @escaping StopSharingCompletionBlock) { + if let shareData = shareRecordData { + let (database, shareID) = shareDatabaseAndRecordID(from: shareData) + + database.delete(withRecordID: shareID) { recordID, error in + completion(error == nil) + } + + if isOwnedByCurrentUser { + setShareRecord(share: nil, in: persistentContainer) + } + } else { + completion(true) + } + } + +} diff --git a/Source/Classes/Sharing/CloudCoreSharingController.swift b/Source/Classes/Sharing/CloudCoreSharingController.swift new file mode 100644 index 00000000..39349f01 --- /dev/null +++ b/Source/Classes/Sharing/CloudCoreSharingController.swift @@ -0,0 +1,103 @@ +// +// CloudCoreSharingController.swift +// CloudCore +// +// Created by deeje cooley on 5/25/21. +// + +#if os(iOS) + +import UIKit +import CoreData +import CloudKit + +public typealias ConfigureSharingCompletionBlock = (_ sharingController: UICloudSharingController?) -> Void + +public class CloudCoreSharingController: NSObject, UICloudSharingControllerDelegate { + + let persistentContainer: NSPersistentContainer + let object: CloudCoreSharing + + public var didSaveShare: ((CKShare)->Void)? + public var didStopSharing: (()->Void)? + public var didError: ((Error)->Void)? + + public init(persistentContainer: NSPersistentContainer, object: CloudCoreSharing) { + self.persistentContainer = persistentContainer + self.object = object + } + + public func configureSharingController(permissions: UICloudSharingController.PermissionOptions, + completion: @escaping ConfigureSharingCompletionBlock) { + + func commonConfigure(_ sharingController: UICloudSharingController) { + sharingController.availablePermissions = permissions + sharingController.delegate = self + completion(sharingController) + } + + guard let aRecord = try! object.restoreRecordWithSystemFields(for: .private) else { completion(nil); return } + + object.fetchShareRecord { share, error in + guard error == nil, let share = share else { completion(nil); return } + + DispatchQueue.main.async { + if share.participants.count > 1 { + let sharingController = UICloudSharingController(share: share, container: CloudCore.config.container) + commonConfigure(sharingController) + } else { + let sharingController = UICloudSharingController { _, handler in + let modifyOp = CKModifyRecordsOperation(recordsToSave: [aRecord, share], recordIDsToDelete: nil) + modifyOp.savePolicy = .changedKeys + modifyOp.qualityOfService = .userInitiated + modifyOp.modifyRecordsCompletionBlock = { records, recordIDs, error in + if let share = records?.first as? CKShare { + handler(share, CloudCore.config.container, error) + } else { + handler(nil, nil, error) + } + } + CloudCore.config.container.privateCloudDatabase.add(modifyOp) + } + + commonConfigure(sharingController) + } + } + } + } + + public func itemTitle(for csc: UICloudSharingController) -> String? { + return object.sharingTitle + } + + public func itemThumbnailData(for csc: UICloudSharingController) -> Data? { + return object.sharingImage + } + + public func itemType(for csc: UICloudSharingController) -> String? { + return object.sharingType + } + + public func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { + if object.isOwnedByCurrentUser && object.shareRecordData == nil { + object.setShareRecord(share: csc.share, in: persistentContainer) + } + + didSaveShare?(csc.share!) + } + + public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { + if object.isOwnedByCurrentUser { + object.setShareRecord(share: nil, in: persistentContainer) + } + + didStopSharing?() + } + + public func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) { + didError?(error) + } + +} + +#endif diff --git a/Source/Enum/Module.swift b/Source/Enum/Module.swift index 2ff7525b..6f31bf2a 100644 --- a/Source/Enum/Module.swift +++ b/Source/Enum/Module.swift @@ -11,10 +11,12 @@ import Foundation /// Enumeration with module name that issued an error in `CloudCoreErrorDelegate` public enum Module { - /// Save to CloudKit module - case saveToCloud + case pushToCloud - /// Fetch from CloudKit module - case fetchFromCloud + case pullFromCloud + + case cacheToCloud + + case cacheFromCloud } diff --git a/Source/Enum/FetchResult.swift b/Source/Enum/PullResult.swift similarity index 50% rename from Source/Enum/FetchResult.swift rename to Source/Enum/PullResult.swift index 816eb629..49715319 100644 --- a/Source/Enum/FetchResult.swift +++ b/Source/Enum/PullResult.swift @@ -1,5 +1,5 @@ // -// FetchResult.swift +// PullResult.swift // CloudCore // // Created by Vasily Ulianov on 08.02.17. @@ -9,12 +9,12 @@ import Foundation -/// Enumeration with results of `FetchAndSaveOperation`. -public enum FetchResult: UInt { +/// Enumeration with results of `PullOperation`. +public enum PullResult: UInt { /// Fetching has successfully completed without any errors case newData = 0 - /// No fetching was done, maybe fired with `FetchAndSaveOperation` was called with incorrect UserInfo without CloudCore's data + /// No fetching was done, maybe fired with `PullOperation` was called with incorrect UserInfo without CloudCore's data case noData = 1 /// There were some errors during operation @@ -24,14 +24,26 @@ public enum FetchResult: UInt { #if os(iOS) import UIKit - public extension FetchResult { + public extension PullResult { /// Convert `self` to `UIBackgroundFetchResult` /// /// Very usefull at `application(_:didReceiveRemoteNotification:fetchCompletionHandler)` as `completionHandler` - public var uiBackgroundFetchResult: UIBackgroundFetchResult { + var uiBackgroundFetchResult: UIBackgroundFetchResult { return UIBackgroundFetchResult(rawValue: self.rawValue)! } } #endif + +#if os(watchOS) + import WatchKit + + public extension PullResult { + + var wkBackgroundFetchResult: WKBackgroundFetchResult { + return WKBackgroundFetchResult(rawValue: self.rawValue)! + } + + } +#endif diff --git a/Source/Extensions/CKRecordID.swift b/Source/Extensions/CKRecordID.swift deleted file mode 100644 index da63c85a..00000000 --- a/Source/Extensions/CKRecordID.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// CloudRecordID.swift -// CloudCore -// -// Created by Vasily Ulianov on 02.02.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import CloudKit - -extension CKRecordID { - private static let separator = "|" - - /// Init from encoded string - /// - /// - Parameter encodedString: format: `recordName|ownerName` - convenience init?(encodedString: String) { - let separated = encodedString.components(separatedBy: CKRecordID.separator) - - if separated.count == 2 { - let zoneID = CKRecordZoneID(zoneName: CloudCore.config.zoneID.zoneName, ownerName: separated[1]) - self.init(recordName: separated[0], zoneID: zoneID) - } else { - return nil - } - } - - /// Encoded string in format: `recordName|ownerName` - var encodedString: String { - return recordName + CKRecordID.separator + zoneID.ownerName - } -} diff --git a/Source/Extensions/NSEntityDescription.swift b/Source/Extensions/NSEntityDescription.swift index 776b2b5b..48b93112 100644 --- a/Source/Extensions/NSEntityDescription.swift +++ b/Source/Extensions/NSEntityDescription.swift @@ -7,6 +7,7 @@ // import CoreData +import CloudKit extension NSEntityDescription { var serviceAttributeNames: ServiceAttributeNames? { @@ -15,64 +16,136 @@ extension NSEntityDescription { let attributeNamesFromUserInfo = self.parseAttributeNamesFromUserInfo() // Get required attributes - - // Record Data - let recordDataName: String - if let recordDataUserInfoName = attributeNamesFromUserInfo.recordData { - recordDataName = recordDataUserInfoName - } else { - // Last chance: try to find default attribute name in entity - if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameRecordData) { - recordDataName = CloudCore.config.defaultAttributeNameRecordData - } else { - return nil - } - } - - // Record ID - let recordIDName: String - if let recordIDUserInfoName = attributeNamesFromUserInfo.recordID { - recordIDName = recordIDUserInfoName - } else { - // Last chance: try to find default attribute name in entity - if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameRecordID) { - recordIDName = CloudCore.config.defaultAttributeNameRecordID - } else { - return nil - } - } - - return ServiceAttributeNames(entityName: entityName, recordData: recordDataName, recordID: recordIDName, isPublic: attributeNamesFromUserInfo.isPublic) + // Record Name + let recordNameAttribute: String + if let recordNameUserInfoName = attributeNamesFromUserInfo.recordName { + recordNameAttribute = recordNameUserInfoName + } else { + // Last chance: try to find default attribute name in entity + if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameRecordName) { + recordNameAttribute = CloudCore.config.defaultAttributeNameRecordName + } else { + return nil + } + } + + // Owner Name + let ownerNameAttribute: String + if let ownerNameUserInfoName = attributeNamesFromUserInfo.ownerName { + ownerNameAttribute = ownerNameUserInfoName + } else { + // Last chance: try to find default attribute name in entity + if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameOwnerName) { + ownerNameAttribute = CloudCore.config.defaultAttributeNameOwnerName + } else { + return nil + } + } + + // Private Record Data + let privateRecordDataAttribute: String + if let recordDataUserInfoName = attributeNamesFromUserInfo.privateRecordData { + privateRecordDataAttribute = recordDataUserInfoName + } else { + // Last chance: try to find default attribute name in entity + if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNamePrivateRecordData) { + privateRecordDataAttribute = CloudCore.config.defaultAttributeNamePrivateRecordData + } else { + return nil + } + } + + // Public Record Data + let publicRecordDataAttribute: String + if let recordDataUserInfoName = attributeNamesFromUserInfo.publicRecordData { + publicRecordDataAttribute = recordDataUserInfoName + } else { + // Last chance: try to find default attribute name in entity + if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNamePublicRecordData) { + publicRecordDataAttribute = CloudCore.config.defaultAttributeNamePublicRecordData + } else { + return nil + } + } + + let relationshipNames = relationshipsByName.map { $0.key } + + return ServiceAttributeNames(entityName: entityName, + scopes: attributeNamesFromUserInfo.scopes, + recordName: recordNameAttribute, + ownerName: ownerNameAttribute, + privateRecordData: privateRecordDataAttribute, + publicRecordData: publicRecordDataAttribute, + allAttributeNames: attributeNamesFromUserInfo.allAttributeNames, + allRelationshipNames: relationshipNames, + maskedUpload: attributeNamesFromUserInfo.maskedUpload, + maskedDownload: attributeNamesFromUserInfo.maskedDownload) } /// Parse data from User Info dictionary - private func parseAttributeNamesFromUserInfo() -> (isPublic: Bool, recordData: String?, recordID: String?) { - var recordDataName: String? - var recordIDName: String? - var isPublic = false - - // In attribute - for (attributeName, attributeDescription) in self.attributesByName { + private func parseAttributeNamesFromUserInfo() -> (scopes: [CKDatabase.Scope], + recordName: String?, + ownerName: String?, + privateRecordData: String?, + publicRecordData: String?, + allAttributeNames: [String], + maskedUpload: [String], + maskedDownload: [String]) { + var scopes: [CKDatabase.Scope] = [] + var recordNameAttribute: String? + var ownerNameAttribute: String? + var privateRecordDataAttribute: String? + var publicRecordDataAttribute: String? + var allAttributeNames: [String] = [] + var maskedUpload: [String] = [] + var maskedDownload: [String] = [] + + func parse(_ attributeName: String, _ userInfo: [AnyHashable: Any]) { + allAttributeNames.append(attributeName) + + for (key, value) in userInfo { + guard let key = key as? String, let value = value as? String else { + continue + } + + if key == ServiceAttributeNames.keyType { + switch value { + case ServiceAttributeNames.valueRecordName: recordNameAttribute = attributeName + case ServiceAttributeNames.valueOwnerName: ownerNameAttribute = attributeName + case ServiceAttributeNames.valuePrivateRecordData: privateRecordDataAttribute = attributeName + case ServiceAttributeNames.valuePublicRecordData: publicRecordDataAttribute = attributeName + default: continue + } + + allAttributeNames.removeLast() + } else if key == ServiceAttributeNames.keyMasks { + let maskStrings = value.components(separatedBy: ",") + if maskStrings.contains("upload") { + maskedUpload.append(attributeName) + } + if maskStrings.contains("download") { + maskedDownload.append(attributeName) + } + } + } + } + + if let userInfo = self.userInfo, let scopesString = userInfo[ServiceAttributeNames.keyScopes] as? String { + let scopeComponents = scopesString.components(separatedBy: ",") + if scopeComponents.contains("public") { + scopes.append(.public) + } + if scopeComponents.contains("private") { + scopes.append(.private) + } + } + + for (attributeName, attributeDescription) in attributesByName { guard let userInfo = attributeDescription.userInfo else { continue } - - // In userInfo dictionary - for (key, value) in userInfo { - guard let key = key as? String, - let value = value as? String else { continue } - - if key == ServiceAttributeNames.keyType { - switch value { - case ServiceAttributeNames.valueRecordID: recordIDName = attributeName - case ServiceAttributeNames.valueRecordData: recordDataName = attributeName - default: continue - } - } else if key == ServiceAttributeNames.keyIsPublic { - if value == "true" { isPublic = true } - } - } + parse(attributeName, userInfo) } - return (isPublic, recordDataName, recordIDName) + return (scopes, recordNameAttribute, ownerNameAttribute, privateRecordDataAttribute, publicRecordDataAttribute, allAttributeNames, maskedUpload, maskedDownload) } } diff --git a/Source/Extensions/NSManagedContainer.swift b/Source/Extensions/NSManagedContainer.swift new file mode 100644 index 00000000..cff87cfe --- /dev/null +++ b/Source/Extensions/NSManagedContainer.swift @@ -0,0 +1,20 @@ +// +// NSManagedObjectContext.swift +// CloudCore +// +// Created by deeje cooley on 5/11/21. +// + +import CoreData + +extension NSPersistentContainer { + + public func performBackgroundPushTask(_ block: @escaping (NSManagedObjectContext) -> Void) { + performBackgroundTask { moc in + moc.name = CloudCore.config.pushContextName + moc.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + block(moc) + } + } + +} diff --git a/Source/Extensions/NSManagedObject.swift b/Source/Extensions/NSManagedObject.swift index 757781ed..1bc325e2 100644 --- a/Source/Extensions/NSManagedObject.swift +++ b/Source/Extensions/NSManagedObject.swift @@ -14,11 +14,12 @@ extension NSManagedObject { /// /// - Returns: unacrhived `CKRecord` containing restored system fields (like RecordID, tokens, creationg date etc) /// - Throws: `CloudCoreError.missingServiceAttributes` if names of CloudCore attributes are not specified in User Info - func restoreRecordWithSystemFields() throws -> CKRecord? { + public func restoreRecordWithSystemFields(for scope: CKDatabase.Scope) throws -> CKRecord? { guard let serviceAttributeNames = self.entity.serviceAttributeNames else { throw CloudCoreError.missingServiceAttributes(entityName: self.entity.name) } - guard let encodedRecordData = self.value(forKey: serviceAttributeNames.recordData) as? Data else { return nil } + let key = scope == .public ? serviceAttributeNames.publicRecordData : serviceAttributeNames.privateRecordData + guard let encodedRecordData = self.value(forKey: key) as? Data else { return nil } return CKRecord(archivedData: encodedRecordData) } @@ -28,18 +29,81 @@ extension NSManagedObject { /// - Postcondition: `self` is modified (recordData and recordID is written) /// - Throws: may throw exception if unable to find attributes marked by User Info as service attributes /// - Returns: new `CKRecord` - @discardableResult func setRecordInformation() throws -> CKRecord { + @discardableResult func setRecordInformation(for scope: CKDatabase.Scope) throws -> CKRecord { guard let entityName = self.entity.name else { throw CloudCoreError.coreData("No entity name for \(self.entity)") } guard let serviceAttributeNames = self.entity.serviceAttributeNames else { throw CloudCoreError.missingServiceAttributes(entityName: self.entity.name) } + + var recordName = self.value(forKey: serviceAttributeNames.recordName) as? String + if recordName == nil { + recordName = UUID().uuidString + self.setValue(recordName, forKey: serviceAttributeNames.recordName) + } + + let aRecord: CKRecord + if scope == .public { + let publicRecordID = CKRecord.ID(recordName: recordName!) + let publicRecord = CKRecord(recordType: entityName, recordID:publicRecordID) + self.setValue(publicRecord.encdodedSystemFields, forKey: serviceAttributeNames.publicRecordData) + + aRecord = publicRecord + } else { + let zoneID = CKRecordZone.ID(zoneName: CloudCore.config.zoneName, ownerName: self.sharingOwnerName) + let privateRecordID = CKRecord.ID(recordName: recordName!, zoneID: zoneID) + let privateRecord = CKRecord(recordType: entityName, recordID: privateRecordID) + self.setValue(privateRecord.encdodedSystemFields, forKey: serviceAttributeNames.privateRecordData) + + aRecord = privateRecord + } + + let ownerName = self.value(forKey: serviceAttributeNames.ownerName) as? String + if ownerName == nil { + self.setValue(aRecord.recordID.zoneID.ownerName, forKey: serviceAttributeNames.ownerName) + } - let record = CKRecord(recordType: entityName, zoneID: CloudCore.config.zoneID) - self.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.recordData) - self.setValue(record.recordID.encodedString, forKey: serviceAttributeNames.recordID) - - return record + return aRecord } + + var parentAttributeName: String? { + get { + return entity.userInfo?[ServiceAttributeNames.keyParent] as? String + } + } + + var sharingOwnerName: String { + get { + if let parentAttributeName = parentAttributeName, + let parent: NSManagedObject = value(forKey: parentAttributeName) as? NSManagedObject, + let serviceAttributes = parent.entity.serviceAttributeNames, + let parentOwnerName: String = parent.value(forKey: serviceAttributes.ownerName) as? String + { + return parentOwnerName + } else if let serviceAttributes = entity.serviceAttributeNames, + let ownerName: String = value(forKey: serviceAttributes.ownerName) as? String + { + return ownerName + } + + return CKCurrentUserDefaultName + } + } + +} + +extension NSManagedObject { + + static var updatedPropertyNamesKey = "NSManagedObject_updatedPropertyNamesKey" + + var updatedPropertyNames: [String]? { + get { + return objc_getAssociatedObject(self, &NSManagedObject.updatedPropertyNamesKey) as? [String] + } + set { + objc_setAssociatedObject(self, &NSManagedObject.updatedPropertyNamesKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + } diff --git a/Source/Extensions/NSManagedObjectModel.swift b/Source/Extensions/NSManagedObjectModel.swift index 49bbbdbc..2d74009f 100644 --- a/Source/Extensions/NSManagedObjectModel.swift +++ b/Source/Extensions/NSManagedObjectModel.swift @@ -21,5 +21,17 @@ extension NSManagedObjectModel { return cloudCoreEntities } + + var desiredKeys: [String] { + var keys: Set = [] + + for entity in self.entities { + if let desired = entity.serviceAttributeNames?.desiredKeys() { + desired.forEach { keys.insert($0) } + } + } + + return keys.map { $0 } + } } diff --git a/Source/Extensions/UIViewController+CloudKit.swift b/Source/Extensions/UIViewController+CloudKit.swift new file mode 100644 index 00000000..825bf63b --- /dev/null +++ b/Source/Extensions/UIViewController+CloudKit.swift @@ -0,0 +1,58 @@ +// +// UIViewController+CloudKit.swift +// CloudCore +// +// Created by deeje cooley on 12/5/20. +// + +#if os(iOS) + +import UIKit +import CloudKit + +extension UIViewController { + + public func iCloudAvailable(withPrompt: Bool = true, completion: @escaping ((Bool) -> Void)) { + CloudCore.config.container.accountStatus { accountStatus, error in + DispatchQueue.main.async { + var available = false + + var title: String? + var message: String? + + switch accountStatus { + case .noAccount: + title = "Sign in to iCloud and\nenable iCloud Drive" + message = "Go to Settings and sign into your iPhone. Under iCloud, enable iCloud Drive." + + case .available: + available = true + + case .couldNotDetermine: + title = "iCloud Unavailable" + message = "Could not determine the status of your iCloud account" + + case .restricted: + title = "iCloud Restricted" + message = "You'll need permissions changed on your iCloud account" + + @unknown default: + break + } + + if withPrompt, let title = title, let message = message { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in })) + self.present(alert, animated: true) { + completion(available) + } + } else { + completion(available) + } + } + } + } + +} + +#endif diff --git a/Source/Model/CKRecord.swift b/Source/Model/CKRecord.swift index 5894f9d7..ec141128 100644 --- a/Source/Model/CKRecord.swift +++ b/Source/Model/CKRecord.swift @@ -8,20 +8,18 @@ import CloudKit -extension CKRecord { +public extension CKRecord { convenience init?(archivedData: Data) { - let unarchiver = NSKeyedUnarchiver(forReadingWith: archivedData) + let unarchiver = try! NSKeyedUnarchiver(forReadingFrom: archivedData) unarchiver.requiresSecureCoding = true self.init(coder: unarchiver) } var encdodedSystemFields: Data { - let archivedData = NSMutableData() - let archiver = NSKeyedArchiver(forWritingWith: archivedData) - archiver.requiresSecureCoding = true + let archiver = NSKeyedArchiver(requiringSecureCoding: true) self.encodeSystemFields(with: archiver) archiver.finishEncoding() - return archivedData as Data + return archiver.encodedData } } diff --git a/Source/Model/CloudCoreConfig.swift b/Source/Model/CloudCoreConfig.swift index 582fc48e..c70d838a 100644 --- a/Source/Model/CloudCoreConfig.swift +++ b/Source/Model/CloudCoreConfig.swift @@ -37,31 +37,50 @@ public struct CloudCoreConfig { /// RecordZone inside private database to store CoreData. /// /// Default value is `CloudCore` - public var zoneID = CKRecordZoneID(zoneName: "CloudCore", ownerName: CKCurrentUserDefaultName) - let subscriptionIDForPrivateDB = "CloudCorePrivate" - let subscriptionIDForSharedDB = "CloudCoreShared" + public var zoneName = "CloudCore" + public func privateZoneID() -> CKRecordZone.ID { + return CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) + } + public let subscriptionIDForPrivateDB = "CloudCorePrivate" + public let subscriptionIDForSharedDB = "CloudCoreShared" /// subscriptionID's prefix for custom CKSubscription in public databases - var publicSubscriptionIDPrefix = "CloudCore-" + public var publicSubscriptionIDPrefix = "CloudCore-" // MARK: Core Data - let contextName = "CloudCoreFetchAndSave" - - /// Default entity's attribute name for *Record ID* if User Info is not specified. - /// - /// Default value is `recordID` - public var defaultAttributeNameRecordID = "recordID" - - /// Default entity's attribute name for *Record Data* if User Info is not specified - /// - /// Default value is `recordData` - public var defaultAttributeNameRecordData = "recordData" - + public let pushContextName = "CloudCorePushContext" + public let pullContextName = "CloudCorePullContext" + + /// Default entity's attribute name for *Record Name* if User Info is not specified + /// + /// Default value is `recordName` + public var defaultAttributeNameRecordName = "recordName" + + /// Default entity's attribute name for *Owner Name* if User Info is not specified + /// + /// Default value is `recordName` + public var defaultAttributeNameOwnerName = "ownerName" + + /// Default entity's attribute name for *Private Record Data* if User Info is not specified + /// + /// Default value is `privateRecordData` + public var defaultAttributeNamePrivateRecordData = "privateRecordData" + + /// Default entity's attribute name for *Public Record Data* if User Info is not specified + /// + /// Default value is `publicRecordData` + public var defaultAttributeNamePublicRecordData = "publicRecordData" + // MARK: User Defaults /// UserDefault's key to store `Tokens` object /// /// Default value is `CloudCoreTokens` public var userDefaultsKeyTokens = "CloudCoreTokens" + public var persistentHistoryTokenKey = "lastPersistentHistoryTokenKey" + public init() { + + } + } diff --git a/Source/Model/CloudKitAttribute.swift b/Source/Model/CloudKitAttribute.swift index 805b0dc1..27ba5a37 100644 --- a/Source/Model/CloudKitAttribute.swift +++ b/Source/Model/CloudKitAttribute.swift @@ -19,6 +19,7 @@ class CloudKitAttribute { let entityName: String let serviceAttributes: ServiceAttributeNames let context: NSManagedObjectContext + var notFoundRecordNamesForAttribute = [AttributeName: [RecordName]]() init(value: Any?, fieldName: String, entityName: String, serviceAttributes: ServiceAttributeNames, context: NSManagedObjectContext) { self.value = value @@ -30,8 +31,10 @@ class CloudKitAttribute { func makeCoreDataValue() throws -> Any? { switch value { - case let reference as CKReference: return try findManagedObject(for: reference.recordID) - case let references as [CKReference]: + case let reference as CKRecord.Reference: + return try findManagedObject(for: reference.recordID) + + case let references as [CKRecord.Reference]: let managedObjects = NSMutableSet() for ref in references { guard let foundObject = try findManagedObject(for: ref.recordID) else { continue } @@ -40,23 +43,34 @@ class CloudKitAttribute { if managedObjects.count == 0 { return nil } return managedObjects - case let asset as CKAsset: return try Data(contentsOf: asset.fileURL) - default: return value + + case let asset as CKAsset: + guard let url = asset.fileURL else { return nil } + return try Data(contentsOf: url) + + default: + return value } } - private func findManagedObject(for recordID: CKRecordID) throws -> NSManagedObject? { + private func findManagedObject(for recordID: CKRecord.ID) throws -> NSManagedObject? { let targetEntityName = try findTargetEntityName() let fetchRequest = NSFetchRequest(entityName: targetEntityName) // FIXME: user serviceAttributes.recordID from target entity (not from me) - fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordID + " == %@" , recordID.encodedString) + fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordName + " == %@" , recordID.recordName) fetchRequest.fetchLimit = 1 fetchRequest.includesPropertyValues = false let foundObject = try context.fetch(fetchRequest).first as? NSManagedObject - + + if foundObject == nil { + var values = notFoundRecordNamesForAttribute[fieldName] ?? [] + values.append(recordID.recordName) + notFoundRecordNamesForAttribute[fieldName] = values + } + return foundObject } diff --git a/Source/Model/ServiceAttributeName.swift b/Source/Model/ServiceAttributeName.swift index 7174eaf4..ff3eb31d 100644 --- a/Source/Model/ServiceAttributeName.swift +++ b/Source/Model/ServiceAttributeName.swift @@ -7,17 +7,50 @@ // import CoreData +import CloudKit struct ServiceAttributeNames { // User Info keys & values static let keyType = "CloudCoreType" - static let keyIsPublic = "CloudCorePublicDatabase" - - static let valueRecordData = "recordData" - static let valueRecordID = "recordID" + static let keyScopes = "CloudCoreScopes" + static let keyParent = "CloudCoreParent" + static let keyMasks = "CloudCoreMasks" + static let keyCacheable = "CloudCoreCacheable" + static let valueRecordName = "recordName" + static let valueOwnerName = "ownerName" + static let valuePrivateRecordData = "privateRecordData" + static let valuePublicRecordData = "publicRecordData" + let entityName: String - let recordData: String - let recordID: String - let isPublic: Bool + + let scopes: [CKDatabase.Scope] + + let recordName: String + let ownerName: String + let privateRecordData: String + let publicRecordData: String + + let allAttributeNames: [String] + let allRelationshipNames: [String] + let maskedUpload: [String] + let maskedDownload: [String] + + func isMaskedUpload(_ attributeName: String) -> Bool { + switch attributeName { + case recordName, ownerName, privateRecordData, publicRecordData: + return true + default: + return maskedUpload.contains(attributeName) + } + } + + func desiredKeys() -> [String] { + var keys = allAttributeNames.filter { !maskedDownload.contains($0) } + + keys.append(contentsOf: allRelationshipNames) + + return keys + } + } diff --git a/Source/Model/Tokens.swift b/Source/Model/Tokens.swift index a91ab8a8..b172876b 100644 --- a/Source/Model/Tokens.swift +++ b/Source/Model/Tokens.swift @@ -9,32 +9,30 @@ import CloudKit /** - CloudCore's class for storing global `CKToken` objects. Framework uses one to upload or download only changed data (smart-sync). - - To detect what data is new and old, framework uses CloudKit's `CKToken` objects and it is needed to be loaded every time application launches and saved on exit. + CloudCore's class for storing global `CKToken` objects. Framework uses one to download only changed data (smart-sync). Framework stores tokens in 2 places: * singleton `Tokens` object in `CloudCore.tokens` * tokens per record inside *Record Data* attribute, it is managed automatically you don't need to take any actions about that token - - You need to save `Tokens` object before application terminates otherwise you will loose smart-sync ability. - - ### Example - ```swift - func applicationWillTerminate(_ application: UIApplication) { - CloudCore.tokens.saveToUserDefaults() - } - ``` */ -open class Tokens: NSObject, NSCoding { + +open class Tokens: NSObject, NSSecureCoding { - var tokensByRecordZoneID = [CKRecordZoneID: CKServerChangeToken]() + private var tokensByDatabaseScope = [Int: CKServerChangeToken]() + private var tokensByRecordZoneID = [CKRecordZone.ID: CKServerChangeToken]() private struct ArchiverKey { - static let tokensByRecordZoneID = "tokensByRecordZoneID" + static let tokensByDatabaseScope = "tokensByDatabaseScope" + static let tokensByRecordZoneID = "tokensByRecordZoneID" } + private let queue = DispatchQueue(label: "com.deeje.CloudCore.Tokens") + + public static var supportsSecureCoding: Bool { + return true + } + /// Create fresh object without any Tokens inside. Can be used to fetch full data. public override init() { super.init() @@ -45,36 +43,65 @@ open class Tokens: NSObject, NSCoding { /// Load saved Tokens from UserDefaults. Key is used from `CloudCoreConfig.userDefaultsKeyTokens` /// /// - Returns: previously saved `Token` object, if tokens weren't saved before newly initialized `Tokens` object will be returned - open static func loadFromUserDefaults() -> Tokens { - guard let tokensData = UserDefaults.standard.data(forKey: CloudCore.config.userDefaultsKeyTokens), - let tokens = NSKeyedUnarchiver.unarchiveObject(with: tokensData) as? Tokens else { - return Tokens() + public static func loadFromUserDefaults() -> Tokens { + if let tokensData = UserDefaults.standard.data(forKey: CloudCore.config.userDefaultsKeyTokens) { + do { + let allowableClasses = [Tokens.classForKeyedUnarchiver(), + NSNumber.classForKeyedUnarchiver(), + NSDictionary.classForKeyedUnarchiver(), + CKRecordZone.ID.classForKeyedUnarchiver(), + CKServerChangeToken.classForKeyedUnarchiver()] + let tokens = try NSKeyedUnarchiver.unarchivedObject(ofClasses: allowableClasses, from: tokensData) as! Tokens + + return tokens + } catch { +// print("\(error)") + } } - return tokens + return Tokens() } /// Save tokens to UserDefaults and synchronize. Key is used from `CloudCoreConfig.userDefaultsKeyTokens` open func saveToUserDefaults() { - let tokensData = NSKeyedArchiver.archivedData(withRootObject: self) - UserDefaults.standard.set(tokensData, forKey: CloudCore.config.userDefaultsKeyTokens) - UserDefaults.standard.synchronize() + if let tokensData = try? NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: true) { + UserDefaults.standard.set(tokensData, forKey: CloudCore.config.userDefaultsKeyTokens) + UserDefaults.standard.synchronize() + } } // MARK: NSCoding /// Returns an object initialized from data in a given unarchiver. public required init?(coder aDecoder: NSCoder) { - if let decodedTokens = aDecoder.decodeObject(forKey: ArchiverKey.tokensByRecordZoneID) as? [CKRecordZoneID: CKServerChangeToken] { - self.tokensByRecordZoneID = decodedTokens - } else { - return nil + if let decodedTokensByScope = aDecoder.decodeObject(forKey: ArchiverKey.tokensByDatabaseScope) as? [Int: CKServerChangeToken] { + self.tokensByDatabaseScope = decodedTokensByScope + } + if let decodedTokensByZone = aDecoder.decodeObject(forKey: ArchiverKey.tokensByRecordZoneID) as? [CKRecordZone.ID: CKServerChangeToken] { + self.tokensByRecordZoneID = decodedTokensByZone } } /// Encodes the receiver using a given archiver. open func encode(with aCoder: NSCoder) { - aCoder.encode(tokensByRecordZoneID, forKey: ArchiverKey.tokensByRecordZoneID) + aCoder.encode(tokensByDatabaseScope, forKey: ArchiverKey.tokensByDatabaseScope) + aCoder.encode(tokensByRecordZoneID, forKey: ArchiverKey.tokensByRecordZoneID) } + func token(for scope: CKDatabase.Scope) -> CKServerChangeToken? { + return queue.sync { tokensByDatabaseScope[scope.rawValue] } + } + + func setToken(_ newToken: CKServerChangeToken?, for scope: CKDatabase.Scope) { + queue.sync { tokensByDatabaseScope[scope.rawValue] = newToken } + } + + func token(for zone: CKRecordZone.ID) -> CKServerChangeToken? { + return queue.sync { tokensByRecordZoneID[zone] } + } + + func setToken(_ newToken: CKServerChangeToken?, for zone: CKRecordZone.ID) { + queue.sync { tokensByRecordZoneID[zone] = newToken } + } + } diff --git a/Source/Protocols/CloudCoreDelegate.swift b/Source/Protocols/CloudCoreDelegate.swift index d0f28cf0..d4492f0c 100644 --- a/Source/Protocols/CloudCoreDelegate.swift +++ b/Source/Protocols/CloudCoreDelegate.swift @@ -11,7 +11,7 @@ import Foundation /// Delegate for framework that can be used for proccesses tracking and error handling. /// Maybe usefull to activate `UIApplication.networkActivityIndicatorVisible`. /// All methods are optional. -public protocol CloudCoreDelegate: class { +public protocol CloudCoreDelegate: AnyObject { // MARK: Notifications diff --git a/Source/Protocols/CloudCoreType.swift b/Source/Protocols/CloudCoreType.swift new file mode 100644 index 00000000..50b0fad7 --- /dev/null +++ b/Source/Protocols/CloudCoreType.swift @@ -0,0 +1,20 @@ +// +// CloudCoreType.swift +// CloudCore +// +// Created by deeje cooley on 5/25/21. +// + +import CoreData + +/// thinking of a typesafe way to identify the required fields + +public protocol CloudCoreType where Self: NSManagedObject { + + var recordName: String? { get } + var ownerName: String? { get } + var publicRecordData: Data? { get } + var privateRecordData: Data? { get set } + +} + diff --git a/Source/Protocols/CloudKitSharing.swift b/Source/Protocols/CloudKitSharing.swift new file mode 100644 index 00000000..6011d1f1 --- /dev/null +++ b/Source/Protocols/CloudKitSharing.swift @@ -0,0 +1,16 @@ +// +// CloudKitSharing.swift +// CloudCore +// +// Created by deeje cooley on 5/25/21. +// + +import CoreData + +public protocol CloudKitSharing where Self: NSManagedObject { + + var sharingTitle: String? { get } + var sharingType: String? { get } + var sharingImage: Data? { get } + +} diff --git a/Tests/CloudCoreTests/Classes/ErrorBlockProxyTests.swift b/Tests/CloudCoreTests/Classes/ErrorBlockProxyTests.swift deleted file mode 100644 index 1092c7a7..00000000 --- a/Tests/CloudCoreTests/Classes/ErrorBlockProxyTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// ErrorBlockProxyTests.swift -// CloudCore -// -// Created by Vasily Ulianov on 02.03.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import XCTest - -@testable import CloudCore - -class ErrorBlockProxyTests: XCTestCase { - func testProxy() { - var isErrorReceived = false - let errorBlock: ErrorBlock = { _ in - isErrorReceived = true - } - - let proxy = ErrorBlockProxy(destination: errorBlock) - - // Check null error - proxy.send(error: nil) - XCTAssertFalse(proxy.wasError) - XCTAssertFalse(isErrorReceived) - - // Check that proxy in proxifing - proxy.send(error: CloudCoreError.custom("test")) - XCTAssertTrue(proxy.wasError) - XCTAssertTrue(isErrorReceived) - } -} diff --git a/Tests/CloudCoreTests/Classes/Fetch/Operations/DeleteFromCoreDataOperationTests.swift b/Tests/CloudCoreTests/Classes/Pull/Operations/DeleteFromCoreDataOperationTests.swift similarity index 88% rename from Tests/CloudCoreTests/Classes/Fetch/Operations/DeleteFromCoreDataOperationTests.swift rename to Tests/CloudCoreTests/Classes/Pull/Operations/DeleteFromCoreDataOperationTests.swift index bb4c2bb7..1a6ad026 100644 --- a/Tests/CloudCoreTests/Classes/Fetch/Operations/DeleteFromCoreDataOperationTests.swift +++ b/Tests/CloudCoreTests/Classes/Pull/Operations/DeleteFromCoreDataOperationTests.swift @@ -19,10 +19,10 @@ class DeleteFromCoreDataOperationTests: CoreDataTestCase { func testOperation() { let remainingObject = TestEntity(context: context) do { - try remainingObject.setRecordInformation() + try remainingObject.setRecordInformation(for: .private) let objectToDelete = TestEntity(context: context) - let record = try objectToDelete.setRecordInformation() + let record = try objectToDelete.setRecordInformation(for: .private) try context.save() @@ -65,7 +65,7 @@ class DeleteFromCoreDataOperationTests: CoreDataTestCase { for _ in 1...300 { let objectToDelete = TestEntity(context: context) do { - let record = try objectToDelete.setRecordInformation() + let record = try objectToDelete.setRecordInformation(for: .private) recordsToDelete.append(record) } catch { XCTFail(error) diff --git a/Tests/CloudCoreTests/Classes/Fetch/Operations/RecordToCoreDataOperationTests.swift b/Tests/CloudCoreTests/Classes/Pull/Operations/RecordToCoreDataOperationTests.swift similarity index 95% rename from Tests/CloudCoreTests/Classes/Fetch/Operations/RecordToCoreDataOperationTests.swift rename to Tests/CloudCoreTests/Classes/Pull/Operations/RecordToCoreDataOperationTests.swift index 38b4633a..252ea62e 100644 --- a/Tests/CloudCoreTests/Classes/Fetch/Operations/RecordToCoreDataOperationTests.swift +++ b/Tests/CloudCoreTests/Classes/Pull/Operations/RecordToCoreDataOperationTests.swift @@ -64,7 +64,7 @@ class RecordToCoreDataOperationTests: CoreDataTestCase { context.performAndWait { // Check operation results let fetchRequest: NSFetchRequest = TestEntity.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "recordID = %@", record.recordID.encodedString) + fetchRequest.predicate = NSPredicate(format: "recordName = %@", record.recordID.recordName) do { guard let managedObject = try context.fetch(fetchRequest).first else { XCTFail("Couldn't find converted object") diff --git a/Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataAttributeTests.swift b/Tests/CloudCoreTests/Classes/Push/ObjectToRecord/CoreDataAttributeTests.swift similarity index 99% rename from Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataAttributeTests.swift rename to Tests/CloudCoreTests/Classes/Push/ObjectToRecord/CoreDataAttributeTests.swift index 2645553c..758a92cd 100644 --- a/Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataAttributeTests.swift +++ b/Tests/CloudCoreTests/Classes/Push/ObjectToRecord/CoreDataAttributeTests.swift @@ -63,7 +63,7 @@ class CoreDataAttributeTests: CoreDataTestCase { do { // External binary if let recordExternalValue = try externalAttribute?.makeRecordValue() as? CKAsset { - let recordData = try Data(contentsOf: recordExternalValue.fileURL) + let recordData = try Data(contentsOf: recordExternalValue.fileURL!) XCTAssertEqual(recordData, externalData) } else { XCTFail("External binary isn't stored correctly") @@ -71,7 +71,7 @@ class CoreDataAttributeTests: CoreDataTestCase { // External big binary if let recordExternalValue = try externalBigAttribute?.makeRecordValue() as? CKAsset { - let recordData = try Data(contentsOf: recordExternalValue.fileURL) + let recordData = try Data(contentsOf: recordExternalValue.fileURL!) XCTAssertEqual(recordData, externalBigData) } else { XCTFail("External big binary isn't stored correctly") diff --git a/Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataRelationshipTests.swift b/Tests/CloudCoreTests/Classes/Push/ObjectToRecord/CoreDataRelationshipTests.swift similarity index 67% rename from Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataRelationshipTests.swift rename to Tests/CloudCoreTests/Classes/Push/ObjectToRecord/CoreDataRelationshipTests.swift index 230ee289..fb652045 100644 --- a/Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataRelationshipTests.swift +++ b/Tests/CloudCoreTests/Classes/Push/ObjectToRecord/CoreDataRelationshipTests.swift @@ -14,23 +14,23 @@ import CloudKit class CoreDataRelationshipTests: CoreDataTestCase { func testInitWithAttribute() { - let relationship = CoreDataRelationship(value: "attribute", relationshipName: "string", entity: TestEntity.entity()) + let relationship = CoreDataRelationship(scope: .private, value: "attribute", relationshipName: "string", entity: TestEntity.entity()) XCTAssertNil(relationship, "Expected nil because it is attribute, not relationship") } func testMakeRecordValues() { // Generate test model let object = TestEntity(context: context) - try! object.setRecordInformation() - let filledObjectRecord = try! object.restoreRecordWithSystemFields()! + try! object.setRecordInformation(for: .private) + let filledObjectRecord = try! object.restoreRecordWithSystemFields(for: .private)! var manyUsers = [UserEntity]() - var manyUsersRecordsIDs = [CKRecordID]() + var manyUsersRecordsIDs = [CKRecord.ID]() for _ in 0...2 { let user = UserEntity(context: context) - try! user.setRecordInformation() - let userRecord = try! user.restoreRecordWithSystemFields()! - user.recordData = userRecord.encdodedSystemFields + try! user.setRecordInformation(for: .private) + let userRecord = try! user.restoreRecordWithSystemFields(for: .private)! + user.privateRecordData = userRecord.encdodedSystemFields manyUsers.append(user) manyUsersRecordsIDs.append(userRecord.recordID) @@ -42,7 +42,7 @@ class CoreDataRelationshipTests: CoreDataTestCase { // Fill testable CKRecord for name in object.entity.relationshipsByName.keys { let managedObjectValue = object.value(forKey: name)! - guard let relationship = CoreDataRelationship(value: managedObjectValue, relationshipName: name, entity: object.entity) else { + guard let relationship = CoreDataRelationship(scope: .private, value: managedObjectValue, relationshipName: name, entity: object.entity) else { XCTFail("Failed to initialize CoreDataRelationship with attribute: \(name)") continue } @@ -56,12 +56,12 @@ class CoreDataRelationshipTests: CoreDataTestCase { } // Check single relationship - let singleReference = filledObjectRecord.value(forKey: "singleRelationship") as! CKReference + let singleReference = filledObjectRecord.value(forKey: "singleRelationship") as! CKRecord.Reference XCTAssertEqual(manyUsersRecordsIDs[0], singleReference.recordID) // Check many relationships - let multipleReferences = filledObjectRecord.value(forKey: "manyRelationship") as! [CKReference] - var filledRecordRelationshipIDs = [CKRecordID]() + let multipleReferences = filledObjectRecord.value(forKey: "manyRelationship") as! [CKRecord.Reference] + var filledRecordRelationshipIDs = [CKRecord.ID]() for recordReference in multipleReferences { filledRecordRelationshipIDs.append(recordReference.recordID) diff --git a/Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/ObjectToRecordOperationTests.swift b/Tests/CloudCoreTests/Classes/Push/ObjectToRecord/ObjectToRecordOperationTests.swift similarity index 77% rename from Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/ObjectToRecordOperationTests.swift rename to Tests/CloudCoreTests/Classes/Push/ObjectToRecord/ObjectToRecordOperationTests.swift index d68ca432..39c0ef2c 100644 --- a/Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/ObjectToRecordOperationTests.swift +++ b/Tests/CloudCoreTests/Classes/Push/ObjectToRecord/ObjectToRecordOperationTests.swift @@ -16,7 +16,7 @@ class ObjectToRecordOperationTests: CoreDataTestCase { func createTestObject(in context: NSManagedObjectContext) -> (TestEntity, CKRecord) { let managedObject = CorrectObject().insert(in: context) - let record = try! managedObject.setRecordInformation() + let record = try! managedObject.setRecordInformation(for: .private) XCTAssertNil(record.value(forKey: "string")) return (managedObject, record) @@ -24,7 +24,7 @@ class ObjectToRecordOperationTests: CoreDataTestCase { func testGoodOperation() { let (managedObject, record) = createTestObject(in: context) - let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + let operation = ObjectToRecordOperation(scope: .private, record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) let conversionExpectation = expectation(description: "ConversionCompleted") operation.errorCompletionBlock = { XCTFail($0) } @@ -40,7 +40,7 @@ class ObjectToRecordOperationTests: CoreDataTestCase { func testContextIsNotDefined() { let record = createTestObject(in: context).1 - let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + let operation = ObjectToRecordOperation(scope: .private, record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) let errorExpectation = expectation(description: "ErrorCalled") operation.errorCompletionBlock = { error in @@ -62,7 +62,7 @@ class ObjectToRecordOperationTests: CoreDataTestCase { let record = CorrectObject().makeRecord() let _ = TestEntity(context: context) - let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + let operation = ObjectToRecordOperation(scope: .private, record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) operation.parentContext = self.context let errorExpectation = expectation(description: "ErrorCalled") @@ -96,7 +96,7 @@ class ObjectToRecordOperationTests: CoreDataTestCase { let queue = OperationQueue() for record in records { - let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + let operation = ObjectToRecordOperation(scope: .private, record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) operation.errorCompletionBlock = { XCTFail($0) } operation.parentContext = backgroundContext queue.addOperation(operation) diff --git a/Tests/CloudCoreTests/CustomFunctions.swift b/Tests/CloudCoreTests/CustomFunctions.swift index 30937eb4..2f7fc8c6 100644 --- a/Tests/CloudCoreTests/CustomFunctions.swift +++ b/Tests/CloudCoreTests/CustomFunctions.swift @@ -9,7 +9,7 @@ import XCTest func XCTAssertThrowsSpecific(_ expression: @autoclosure () throws -> T, _ error: Error) { - XCTAssertThrowsError(expression) { (throwedError) in + XCTAssertThrowsError(try expression()) { (throwedError) in XCTAssertEqual("\(throwedError)", "\(error)", "XCTAssertThrowsSpecific: errors are not equal") } } diff --git a/Tests/CloudCoreTests/Extensions/CKRecordIDTests.swift b/Tests/CloudCoreTests/Extensions/CKRecordIDTests.swift deleted file mode 100644 index 7ca18910..00000000 --- a/Tests/CloudCoreTests/Extensions/CKRecordIDTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// CKRecordID.swift -// CloudCore -// -// Created by Vasily Ulianov on 01.03.17. -// Copyright © 2017 Vasily Ulianov. All rights reserved. -// - -import XCTest -import CloudKit - -@testable import CloudCore - -class CKRecordIDTests: XCTestCase { - func testRecordIDEncodeDecode() { - let zoneID = CKRecordZoneID(zoneName: CloudCore.config.zoneID.zoneName, ownerName: CKCurrentUserDefaultName) - let recordID = CKRecordID(recordName: "testName", zoneID: zoneID) - - let encodedString = recordID.encodedString - let restoredRecordID = CKRecordID(encodedString: encodedString) - - XCTAssertEqual(recordID.recordName, restoredRecordID?.recordName) - XCTAssertEqual(recordID.zoneID, restoredRecordID?.zoneID) - - } -} diff --git a/Tests/CloudCoreTests/Extensions/NSEntityDescriptionTests.swift b/Tests/CloudCoreTests/Extensions/NSEntityDescriptionTests.swift index 7264cc01..1e21947c 100644 --- a/Tests/CloudCoreTests/Extensions/NSEntityDescriptionTests.swift +++ b/Tests/CloudCoreTests/Extensions/NSEntityDescriptionTests.swift @@ -17,8 +17,9 @@ class NSEntityDescriptionTests: CoreDataTestCase { let attributeNames = correctObject.entity.serviceAttributeNames XCTAssertEqual(attributeNames?.entityName, "TestEntity") - XCTAssertEqual(attributeNames?.recordData, "recordData") - XCTAssertEqual(attributeNames?.recordID, "recordID") + XCTAssertEqual(attributeNames?.publicRecordData, "publicRecordData") + XCTAssertEqual(attributeNames?.privateRecordData, "privateRecordData") + XCTAssertEqual(attributeNames?.recordName, "recordName") let incorrectObject = IncorrectEntity(context: self.context) XCTAssertNil(incorrectObject.entity.serviceAttributeNames) diff --git a/Tests/CloudCoreTests/Extensions/NSManagedObjectTests.swift b/Tests/CloudCoreTests/Extensions/NSManagedObjectTests.swift index e972c018..7b15fa91 100644 --- a/Tests/CloudCoreTests/Extensions/NSManagedObjectTests.swift +++ b/Tests/CloudCoreTests/Extensions/NSManagedObjectTests.swift @@ -13,14 +13,15 @@ import CloudKit @testable import CloudCore class NSManagedObjectTests: CoreDataTestCase { + func testRestoreRecordWithSystemFields() { let object = TestEntity(context: context) do { - try object.setRecordInformation() + try object.setRecordInformation(for: .private) - let record = try object.restoreRecordWithSystemFields() + let record = try object.restoreRecordWithSystemFields(for: .private) XCTAssertEqual(record?.recordType, "TestEntity") - XCTAssertEqual(record?.recordID.zoneID, CloudCore.config.zoneID) + XCTAssertEqual(record?.recordID.zoneID, CloudCore.config.privateZoneID()) } catch { XCTFail("\(error)") } @@ -30,7 +31,7 @@ class NSManagedObjectTests: CoreDataTestCase { func testRestoreObjectWithoutData() { let object = TestEntity(context: context) do { - let record = try object.restoreRecordWithSystemFields() + let record = try object.restoreRecordWithSystemFields(for: .private) XCTAssertNil(record) } catch { XCTFail("\(error)") @@ -42,12 +43,12 @@ class NSManagedObjectTests: CoreDataTestCase { func testSetRecordInformationThrow() { let object = IncorrectEntity(context: context) - XCTAssertThrowsSpecific(try object.setRecordInformation(), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) + XCTAssertThrowsSpecific(try object.setRecordInformation(for: .private), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) } func testRestoreRecordThrow() { let object = IncorrectEntity(context: context) - XCTAssertThrowsSpecific(try object.restoreRecordWithSystemFields(), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) + XCTAssertThrowsSpecific(try object.restoreRecordWithSystemFields(for: .private), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) } } diff --git a/Tests/CloudCoreTests/Model/CKRecordTests.swift b/Tests/CloudCoreTests/Model/CKRecordTests.swift index cacaea0d..8aea2089 100644 --- a/Tests/CloudCoreTests/Model/CKRecordTests.swift +++ b/Tests/CloudCoreTests/Model/CKRecordTests.swift @@ -13,8 +13,9 @@ import CloudKit class CKRecordTests: XCTestCase { func testEncodeAndInit() { - let zoneID = CKRecordZoneID(zoneName: "zone", ownerName: CKCurrentUserDefaultName) - let record = CKRecord(recordType: "type", zoneID: zoneID) + let zoneID = CKRecordZone.ID(zoneName: "zone", ownerName: CKCurrentUserDefaultName) + let recordID = CKRecord.ID(recordName: "name", zoneID: zoneID) + let record = CKRecord(recordType: "type", recordID: recordID) record.setValue("testValue", forKey: "testKey") let encodedData = record.encdodedSystemFields diff --git a/Tests/CloudCoreTests/model.xcdatamodeld/model.xcdatamodel/contents b/Tests/CloudCoreTests/model.xcdatamodeld/model.xcdatamodel/contents index 3b6591ce..8ead8015 100644 --- a/Tests/CloudCoreTests/model.xcdatamodeld/model.xcdatamodel/contents +++ b/Tests/CloudCoreTests/model.xcdatamodeld/model.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -13,39 +13,65 @@ - + - + - + - + + + + + + + + + + + + + + - + + + + + + + + + + + - + - + - + + + + - - + + \ No newline at end of file diff --git a/Tests/CloudKitTests/App/AppDelegate.swift b/Tests/CloudKitTests/App/AppDelegate.swift index 11f5e4d3..ba7c8ad5 100644 --- a/Tests/CloudKitTests/App/AppDelegate.swift +++ b/Tests/CloudKitTests/App/AppDelegate.swift @@ -15,7 +15,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. return true } diff --git a/Tests/CloudKitTests/CloudKitTests.swift b/Tests/CloudKitTests/CloudKitTests.swift index 17421bae..cf545a28 100644 --- a/Tests/CloudKitTests/CloudKitTests.swift +++ b/Tests/CloudKitTests/CloudKitTests.swift @@ -19,6 +19,8 @@ class CloudKitTests: CoreDataTestCase { super.setUp() configureCloudKitIfNeeded() CloudKitTests.deleteAllRecordsFromCloudKit() + + context.name = CloudCore.config.pushContextName } override class func tearDown() { @@ -50,7 +52,7 @@ class CloudKitTests: CoreDataTestCase { // Fetch data from CloudKit let fetchExpectation = expectation(description: "fetchExpectation") - CloudCore.fetchAndSave(to: freshPersistentContainer, error: { (error) in + CloudCore.pull(to: freshPersistentContainer, error: { (error) in XCTFail("Error while trying to fetch from CloudKit: \(error)") }) { fetchExpectation.fulfill() diff --git a/Tests/CloudKitTests/Helpers.swift b/Tests/CloudKitTests/Helpers.swift index dc3d941d..bdeebdbf 100644 --- a/Tests/CloudKitTests/Helpers.swift +++ b/Tests/CloudKitTests/Helpers.swift @@ -35,14 +35,14 @@ extension CoreDataTestCase { wait(for: [didSyncExpectation], timeout: 10) - let fetchAndSaveExpectation = expectation(description: "fetchAndSave") - CloudCore.fetchAndSave(to: persistentContainer, error: { (error) in - XCTFail("fetchAndSave error: \(error)") + let pullExpectation = expectation(description: "pull") + CloudCore.pull(to: persistentContainer, error: { (error) in + XCTFail("pull error: \(error)") }) { - fetchAndSaveExpectation.fulfill() + pullExpectation.fulfill() } - wait(for: [fetchAndSaveExpectation], timeout: 10) + wait(for: [pullExpectation], timeout: 10) UserDefaults.standard.set(true, forKey: "isCloudKitConfigured") delegateListener.didSyncToCloudBlock = nil @@ -50,7 +50,7 @@ extension CoreDataTestCase { static func deleteAllRecordsFromCloudKit() { let operationQueue = OperationQueue() - var recordIdsToDelete = [CKRecordID]() + var recordIdsToDelete = [CKRecord.ID]() let publicDatabase = CKContainer.default().privateCloudDatabase let queries = [ @@ -75,7 +75,7 @@ extension CoreDataTestCase { if recordIdsToDelete.isEmpty { return } let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIdsToDelete) - deleteOperation.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, deletedRecordIDs: [CKRecordID]?, error: Error?) in + deleteOperation.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, deletedRecordIDs: [CKRecord.ID]?, error: Error?) in if let error = error { XCTFail("Error while tried to clean test objects: \(error)") } diff --git a/Tests/CloudKitTests/Resources/model.xcdatamodeld/model.xcdatamodel/contents b/Tests/CloudKitTests/Resources/model.xcdatamodeld/model.xcdatamodel/contents index 7f8e6fcb..ed7b0aae 100644 --- a/Tests/CloudKitTests/Resources/model.xcdatamodeld/model.xcdatamodel/contents +++ b/Tests/CloudKitTests/Resources/model.xcdatamodeld/model.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -13,12 +13,14 @@ - + + - + + @@ -27,25 +29,33 @@ + + + - + + - + + + + + - - + + \ No newline at end of file diff --git a/Tests/Shared/CoreDataTestCase.swift b/Tests/Shared/CoreDataTestCase.swift index f32b3445..5bbe9833 100644 --- a/Tests/Shared/CoreDataTestCase.swift +++ b/Tests/Shared/CoreDataTestCase.swift @@ -22,6 +22,9 @@ class CoreDataTestCase: XCTestCase { let container = NSPersistentContainer(name: "model", managedObjectModel: model) let description = NSPersistentStoreDescription() description.type = NSInMemoryStoreType + if #available(iOS 11.0, watchOS 4.0, tvOS 11.0, OSX 10.13, *) { + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + } container.persistentStoreDescriptions = [description] let expect = expectation(description: "CoreDataStackInitialize") diff --git a/Tests/Shared/CorrectObject.swift b/Tests/Shared/CorrectObject.swift index 6694acd3..606115ec 100644 --- a/Tests/Shared/CorrectObject.swift +++ b/Tests/Shared/CorrectObject.swift @@ -38,7 +38,7 @@ struct CorrectObject { let managedObject = TestEntity(context: context) // Header - managedObject.recordData = self.recordData as Data + managedObject.privateRecordData = self.recordData as Data // Binary managedObject.binary = binary as Data @@ -76,8 +76,9 @@ struct CorrectObject { } func makeRecord() -> CKRecord { - let record = CKRecord(recordType: "TestEntity", zoneID: CloudCore.config.zoneID) - + let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: CloudCore.config.privateZoneID()) + let record = CKRecord(recordType: "TestEntity", recordID: recordID) + let asset = try? CoreDataAttribute.createAsset(for: externalBinary) XCTAssertNotNil(asset) record.setValue(asset, forKey: "externalBinary") @@ -100,7 +101,7 @@ struct CorrectObject { func assertEqualAttributes(_ managedObject: TestEntity, _ record: CKRecord) { // Headers - if let encodedRecordData = managedObject.recordData as Data? { + if let encodedRecordData = managedObject.privateRecordData as Data? { let recordFromObject = CKRecord(archivedData: encodedRecordData) XCTAssertEqual(recordFromObject?.recordID, record.recordID) @@ -143,7 +144,7 @@ func assertEqualPlainTextAttributes(_ managedObject: TestEntity, _ record: CKRec func assertEqualBinaryAttributes(_ managedObject: TestEntity, _ record: CKRecord) { if let recordAsset = record.value(forKey: "externalBinary") as! CKAsset? { - let downloadedData = try! Data(contentsOf: recordAsset.fileURL) + let downloadedData = try! Data(contentsOf: recordAsset.fileURL!) XCTAssertEqual(managedObject.externalBinary, downloadedData) }