├── .gitignore ├── .swift-version ├── .travis.yml ├── CloudCore.podspec ├── CloudCore.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ ├── xcbaselines │ └── E29BB2271E436F310020F5B6.xcbaseline │ │ ├── B42B2E0B-5811-46E5-BF5E-3CC5E12577DD.plist │ │ └── Info.plist │ └── xcschemes │ └── CloudCore.xcscheme ├── Example ├── .gitignore ├── CloudCoreExample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── CloudCoreExample.xcscheme ├── CloudCoreExample.xcworkspace │ └── contents.xcworkspacedata ├── Podfile ├── Resources │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── TestImage.imageset │ │ │ ├── Contents.json │ │ │ └── TestImage.jpg │ │ ├── avatar_1.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_1.jpg │ │ ├── avatar_2.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_2.jpg │ │ ├── avatar_3.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_3.jpg │ │ ├── avatar_4.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_4.jpg │ │ ├── avatar_5.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_5.jpg │ │ ├── avatar_6.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_6.jpg │ │ ├── avatar_7.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_7.jpg │ │ ├── avatar_8.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_8.jpg │ │ └── avatar_9.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_9.jpg │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── CloudCoreExample.entitlements │ ├── Info.plist │ └── Model.xcdatamodeld │ │ └── Model.xcdatamodel │ │ └── contents └── Sources │ ├── AppDelegate.swift │ ├── Class │ ├── FRCTableViewDataSource.swift │ ├── ModelFactory.swift │ └── NotificationsObserver.swift │ ├── View Controller │ ├── DetailViewController.swift │ └── MasterViewController.swift │ └── View │ └── EmployeeTableViewCell.swift ├── LICENSE.md ├── README.md ├── Source ├── Classes │ ├── AsynchronousOperation.swift │ ├── CloudCore.swift │ ├── ErrorBlockProxy.swift │ ├── Fetch │ │ ├── FetchAndSaveOperation.swift │ │ ├── PublicSubscriptions │ │ │ ├── FetchPublicSubscriptionsOperation.swift │ │ │ └── PublicDatabaseSubscriptions.swift │ │ └── SubOperations │ │ │ ├── DeleteFromCoreDataOperation.swift │ │ │ ├── FetchRecordZoneChangesOperation.swift │ │ │ ├── PurgeLocalDatabaseOperation.swift │ │ │ └── RecordToCoreDataOperation.swift │ ├── Save │ │ ├── CloudSaveOperationQueue.swift │ │ ├── CoreDataListener.swift │ │ ├── Model │ │ │ ├── RecordIDWithDatabase.swift │ │ │ └── RecordWithDatabase.swift │ │ └── ObjectToRecord │ │ │ ├── CoreDataAttribute.swift │ │ │ ├── CoreDataRelationship.swift │ │ │ ├── ObjectToRecordConverter.swift │ │ │ └── ObjectToRecordOperation.swift │ └── Setup Operation │ │ ├── CreateCloudCoreZoneOperation.swift │ │ ├── SetupOperation.swift │ │ ├── SubscribeOperation.swift │ │ └── UploadAllLocalDataOperation.swift ├── Enum │ ├── CloudCoreError.swift │ ├── FetchResult.swift │ └── Module.swift ├── Extensions │ ├── CKRecordID.swift │ ├── NSEntityDescription.swift │ ├── NSManagedObject.swift │ └── NSManagedObjectModel.swift ├── Model │ ├── CKRecord.swift │ ├── CloudCoreConfig.swift │ ├── CloudKitAttribute.swift │ ├── ServiceAttributeName.swift │ └── Tokens.swift ├── Protocols │ └── CloudCoreDelegate.swift └── Resources │ └── Info.plist └── Tests ├── CloudCoreTests ├── Classes │ ├── ErrorBlockProxyTests.swift │ ├── Fetch │ │ └── Operations │ │ │ ├── DeleteFromCoreDataOperationTests.swift │ │ │ └── RecordToCoreDataOperationTests.swift │ └── Upload │ │ └── ObjectToRecord │ │ ├── CoreDataAttributeTests.swift │ │ ├── CoreDataRelationshipTests.swift │ │ └── ObjectToRecordOperationTests.swift ├── CustomFunctions.swift ├── Extensions │ ├── CKRecordIDTests.swift │ ├── NSEntityDescriptionTests.swift │ └── NSManagedObjectTests.swift ├── Info.plist ├── Model │ └── CKRecordTests.swift └── model.xcdatamodeld │ └── model.xcdatamodel │ └── contents ├── CloudKitTests ├── App │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Info.plist │ ├── TestableApp.entitlements │ ├── TestableApp.xcdatamodeld │ │ ├── .xccurrentversion │ │ └── TestableApp.xcdatamodel │ │ │ └── contents │ └── ViewController.swift ├── CloudKitTests.swift ├── CorrectObjectExtension.swift ├── Helpers.swift └── Resources │ ├── Info.plist │ └── model.xcdatamodeld │ └── model.xcdatamodel │ └── contents └── Shared ├── CoreDataTestCase.swift └── CorrectObject.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | .build/ 41 | 42 | # CocoaPods 43 | # 44 | # We recommend against adding the Pods directory to your .gitignore. However 45 | # you should judge for yourself, the pros and cons are mentioned at: 46 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 47 | # 48 | Pods/ 49 | 50 | # Carthage 51 | # 52 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 53 | # Carthage/Checkouts 54 | 55 | Carthage/Build 56 | 57 | # fastlane 58 | # 59 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 60 | # screenshots whenever they are needed. 61 | # For more information about the recommended setup visit: 62 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 63 | 64 | fastlane/report.xml 65 | fastlane/Preview.html 66 | fastlane/screenshots 67 | fastlane/test_output 68 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 4.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | osx_image: xcode9.2 2 | language: objective-c 3 | podfile: "Example/Podfile" 4 | 5 | branches: 6 | only: 7 | - master 8 | 9 | env: 10 | - DESTINATION='platform=OS X' POD_LINT="YES" 11 | - DESTINATION='platform=iOS Simulator,name=iPhone 6S' BUILD_EXAMPLE="YES" 12 | - DESTINATION='platform=watchOS Simulator,name=Apple Watch - 38mm' SKIP_TEST="YES" 13 | - DESTINATION='platform=tvOS Simulator,name=Apple TV 4K' 14 | 15 | before_install: 16 | - gem install xcpretty-travis-formatter 17 | 18 | script: 19 | - set -o pipefail 20 | - xcodebuild -scheme CloudCore -destination "$DESTINATION" build | xcpretty -f `xcpretty-travis-formatter` 21 | - if [ "$SKIP_TEST" != "YES" ]; then 22 | xcodebuild -scheme CloudCore -destination "$DESTINATION" test | xcpretty -f `xcpretty-travis-formatter`; 23 | fi 24 | 25 | # Example 26 | - if [ "$BUILD_EXAMPLE" = "YES" ]; then 27 | xcodebuild -workspace "Example/CloudCoreExample.xcworkspace" -scheme "CloudCoreExample" -destination "$DESTINATION" build | xcpretty -f `xcpretty-travis-formatter`; 28 | fi 29 | 30 | - if [ "$POD_LINT" = "YES" ]; then 31 | pod lib lint --allow-warnings; 32 | fi 33 | 34 | # Run release to master branch 35 | - if [ "$POD_LINT" = "YES" ] && [ "$TRAVIS_BRANCH" = "master" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then 36 | pod spec lint --allow-warnings; 37 | fi 38 | -------------------------------------------------------------------------------- /CloudCore.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "CloudCore" 3 | s.summary = "Framework that enables synchronization between CloudKit (iCloud) and Core Data. Can be used as CloudKit caching mechanism." 4 | s.version = "2.0.1" 5 | s.homepage = "https://github.com/sorix/CloudCore" 6 | s.license = 'MIT' 7 | s.author = { "Vasily Ulianov" => "vasily@me.com" } 8 | s.source = { 9 | :git => "https://github.com/sorix/CloudCore.git", 10 | :tag => s.version.to_s 11 | } 12 | 13 | s.ios.deployment_target = '10.0' 14 | s.osx.deployment_target = '10.12' 15 | s.tvos.deployment_target = '10.0' 16 | s.watchos.deployment_target = '3.0' 17 | 18 | s.source_files = 'Source/**/*.swift' 19 | 20 | s.ios.frameworks = 'Foundation', 'CloudKit', 'CoreData' 21 | s.osx.frameworks = 'Foundation', 'CloudKit', 'CoreData' 22 | 23 | s.pod_target_xcconfig = { 'SWIFT_VERSION' => '4.0' } 24 | s.documentation_url = 'http://cocoadocs.org/docsets/CloudCore/' 25 | end 26 | -------------------------------------------------------------------------------- /CloudCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/B42B2E0B-5811-46E5-BF5E-3CC5E12577DD.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | DeleteFromCoreDataOperationTests 8 | 9 | testOperationPerfomance() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.197 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | ObjectToRecordOperationTests 21 | 22 | testOperationPerfomance() 23 | 24 | com.apple.XCTPerformanceMetric_WallClockTime 25 | 26 | baselineAverage 27 | 0.18121 28 | baselineIntegrationDisplayName 29 | Local Baseline 30 | 31 | 32 | 33 | RecordToCoreDataOperationTests 34 | 35 | testDateFormatterPerformance() 36 | 37 | com.apple.XCTPerformanceMetric_WallClockTime 38 | 39 | baselineAverage 40 | 0.117 41 | baselineIntegrationDisplayName 42 | Local Baseline 43 | 44 | 45 | testOperationsPerformance() 46 | 47 | com.apple.XCTPerformanceMetric_WallClockTime 48 | 49 | baselineAverage 50 | 0.083 51 | baselineIntegrationDisplayName 52 | Local Baseline 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runDestinationsByUUID 6 | 7 | B42B2E0B-5811-46E5-BF5E-3CC5E12577DD 8 | 9 | localComputer 10 | 11 | busSpeedInMHz 12 | 100 13 | cpuCount 14 | 1 15 | cpuKind 16 | Intel Core i7 17 | cpuSpeedInMHz 18 | 2500 19 | logicalCPUCoresPerPackage 20 | 8 21 | modelCode 22 | MacBookPro11,5 23 | physicalCPUCoresPerPackage 24 | 4 25 | platformIdentifier 26 | com.apple.platform.macosx 27 | 28 | targetArchitecture 29 | x86_64 30 | targetDevice 31 | 32 | modelCode 33 | iPhone9,2 34 | platformIdentifier 35 | com.apple.platform.iphonesimulator 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 35 | 41 | 42 | 43 | 44 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 67 | 68 | 74 | 75 | 76 | 77 | 78 | 79 | 85 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /Example/.gitignore: -------------------------------------------------------------------------------- 1 | Pods/ 2 | Podfile.lock 3 | -------------------------------------------------------------------------------- /Example/CloudCoreExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/CloudCoreExample.xcodeproj/xcshareddata/xcschemes/CloudCoreExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 75 | 77 | 83 | 84 | 85 | 86 | 88 | 89 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /Example/CloudCoreExample.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment the next line to define a global platform for your project 2 | platform :ios, '10.0' 3 | 4 | target 'CloudCoreExample' do 5 | # Comment the next line if you're not using Swift and don't want to use dynamic frameworks 6 | use_frameworks! 7 | 8 | # Pods for CloudCoreExample 9 | pod 'Fakery', '~> 3.3.0' 10 | end 11 | -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ios-marketing", 45 | "size" : "1024x1024", 46 | "scale" : "1x" 47 | } 48 | ], 49 | "info" : { 50 | "version" : 1, 51 | "author" : "xcode" 52 | } 53 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/TestImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "TestImage.jpg", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/TestImage.imageset/TestImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/TestImage.imageset/TestImage.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_1.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_1.imageset/avatar_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_1.imageset/avatar_1.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_2.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_2.imageset/avatar_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_2.imageset/avatar_2.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_3.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_3.imageset/avatar_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_3.imageset/avatar_3.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_4.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_4.imageset/avatar_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_4.imageset/avatar_4.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_5.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_5.imageset/avatar_5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_5.imageset/avatar_5.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_6.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_6.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_6.imageset/avatar_6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_6.imageset/avatar_6.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_7.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_7.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_7.imageset/avatar_7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_7.imageset/avatar_7.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_8.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_8.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_8.imageset/avatar_8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_8.imageset/avatar_8.jpg -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_9.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "avatar_9.jpg", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Example/Resources/Assets.xcassets/avatar_9.imageset/avatar_9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sorix/CloudCore/7b45e92c298185a8b123480302de9662f51ae38e/Example/Resources/Assets.xcassets/avatar_9.imageset/avatar_9.jpg -------------------------------------------------------------------------------- /Example/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Example/Resources/CloudCoreExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.$(CFBundleIdentifier) 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.developer.ubiquity-kvstore-identifier 16 | $(TeamIdentifierPrefix)$(CFBundleIdentifier) 17 | 18 | 19 | -------------------------------------------------------------------------------- /Example/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.1 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIBackgroundModes 24 | 25 | remote-notification 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UIRequiredDeviceCapabilities 32 | 33 | armv7 34 | 35 | UIStatusBarTintParameters 36 | 37 | UINavigationBar 38 | 39 | Style 40 | UIBarStyleDefault 41 | Translucent 42 | 43 | 44 | 45 | UISupportedInterfaceOrientations 46 | 47 | UIInterfaceOrientationPortrait 48 | UIInterfaceOrientationLandscapeLeft 49 | UIInterfaceOrientationLandscapeRight 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Example/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Example/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CloudTest2 4 | // 5 | // Created by Vasily Ulianov on 14.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import CoreData 11 | import CloudCore 12 | 13 | let persistentContainer = (UIApplication.shared.delegate as! AppDelegate).persistentContainer 14 | 15 | @UIApplicationMain 16 | class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { 17 | 18 | let delegateHandler = CloudCoreDelegateHandler() 19 | 20 | func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool { 21 | // Register for push notifications about changes 22 | application.registerForRemoteNotifications() 23 | 24 | // Enable uploading changed local data to CoreData 25 | CloudCore.delegate = delegateHandler 26 | CloudCore.enable(persistentContainer: persistentContainer) 27 | 28 | return true 29 | } 30 | 31 | // Notification from CloudKit about changes in remote database 32 | func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { 33 | // Check if it CloudKit's and CloudCore notification 34 | if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { 35 | // Fetch changed data from iCloud 36 | CloudCore.fetchAndSave(using: userInfo, to: persistentContainer, error: { 37 | print("fetchAndSave from didReceiveRemoteNotification error: \($0)") 38 | }, completion: { (fetchResult) in 39 | completionHandler(fetchResult.uiBackgroundFetchResult) 40 | }) 41 | } 42 | } 43 | 44 | func applicationWillTerminate(_ application: UIApplication) { 45 | // Save tokens on exit used to differential sync 46 | CloudCore.tokens.saveToUserDefaults() 47 | } 48 | 49 | // MARK: - Default Apple initialization, you can skip that 50 | 51 | var window: UIWindow? 52 | 53 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 54 | return true 55 | } 56 | 57 | // MARK: Core Data stack 58 | 59 | lazy var persistentContainer: NSPersistentContainer = { 60 | /* 61 | The persistent container for the application. This implementation 62 | creates and returns a container, having loaded the store for the 63 | application to it. This property is optional since there are legitimate 64 | error conditions that could cause the creation of the store to fail. 65 | */ 66 | let container = NSPersistentContainer(name: "Model") 67 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 68 | if let error = error as NSError? { 69 | // Replace this implementation with code to handle the error appropriately. 70 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 71 | 72 | /* 73 | Typical reasons for an error here include: 74 | * The parent directory does not exist, cannot be created, or disallows writing. 75 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 76 | * The device is out of space. 77 | * The store could not be migrated to the current model version. 78 | Check the error message to determine what the actual problem was. 79 | */ 80 | fatalError("Unresolved error \(error), \(error.userInfo)") 81 | } 82 | }) 83 | container.viewContext.automaticallyMergesChangesFromParent = true 84 | return container 85 | }() 86 | 87 | // MARK: Core Data Saving support 88 | 89 | func saveContext () { 90 | let context = persistentContainer.viewContext 91 | if context.hasChanges { 92 | do { 93 | try context.save() 94 | } catch { 95 | // Replace this implementation with code to handle the error appropriately. 96 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 97 | let nserror = error as NSError 98 | fatalError("Unresolved error \(nserror), \(nserror.userInfo)") 99 | } 100 | } 101 | } 102 | 103 | } 104 | 105 | -------------------------------------------------------------------------------- /Example/Sources/Class/FRCTableViewDataSource.swift: -------------------------------------------------------------------------------- 1 | // FRCTableViewDataSource.swift 2 | // Gist from: https://gist.github.com/Sorix/987af88f82c95ff8c30b51b6a5620657 3 | 4 | import UIKit 5 | import CoreData 6 | 7 | protocol FRCTableViewDelegate: class { 8 | func frcTableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell 9 | } 10 | 11 | class FRCTableViewDataSource: NSObject, UITableViewDataSource, NSFetchedResultsControllerDelegate { 12 | 13 | let frc: NSFetchedResultsController 14 | weak var tableView: UITableView? 15 | weak var delegate: FRCTableViewDelegate? 16 | 17 | init(fetchRequest: NSFetchRequest, context: NSManagedObjectContext, sectionNameKeyPath: String?) { 18 | frc = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: sectionNameKeyPath, cacheName: nil) 19 | 20 | super.init() 21 | 22 | frc.delegate = self 23 | } 24 | 25 | convenience init(fetchRequest: NSFetchRequest, context: NSManagedObjectContext, sectionNameKeyPath: String?, delegate: FRCTableViewDelegate, tableView: UITableView) { 26 | self.init(fetchRequest: fetchRequest, context: context, sectionNameKeyPath: sectionNameKeyPath) 27 | 28 | self.delegate = delegate 29 | self.tableView = tableView 30 | } 31 | 32 | func performFetch() throws { 33 | try frc.performFetch() 34 | } 35 | 36 | func object(at indexPath: IndexPath) -> FetchRequestResult { 37 | return frc.object(at: indexPath) 38 | } 39 | 40 | // MARK: - UITableViewDataSource 41 | 42 | func numberOfSections(in tableView: UITableView) -> Int { 43 | return frc.sections?.count ?? 0 44 | } 45 | 46 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 47 | guard let sections = frc.sections else { return 0 } 48 | 49 | return sections[section].numberOfObjects 50 | } 51 | 52 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 53 | if let delegate = delegate { 54 | return delegate.frcTableView(tableView, cellForRowAt: indexPath) 55 | } else { 56 | return UITableViewCell() 57 | } 58 | } 59 | 60 | // MARK: - NSFetchedResultsControllerDelegate 61 | 62 | func controllerWillChangeContent(_ controller: NSFetchedResultsController) { 63 | tableView?.beginUpdates() 64 | } 65 | 66 | func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { 67 | let sectionIndexSet = IndexSet(integer: sectionIndex) 68 | 69 | switch type { 70 | case .insert: tableView?.insertSections(sectionIndexSet, with: .automatic) 71 | case .delete: tableView?.deleteSections(sectionIndexSet, with: .automatic) 72 | case .update: tableView?.reloadSections(sectionIndexSet, with: .automatic) 73 | case .move: break 74 | } 75 | } 76 | 77 | func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { 78 | switch type { 79 | case .insert: 80 | guard let newIndexPath = newIndexPath else { break } 81 | tableView?.insertRows(at: [newIndexPath], with: .automatic) 82 | case .delete: 83 | guard let indexPath = indexPath else { break } 84 | tableView?.deleteRows(at: [indexPath], with: .automatic) 85 | case .update: 86 | guard let indexPath = indexPath else { break } 87 | tableView?.reloadRows(at: [indexPath], with: .automatic) 88 | case .move: 89 | guard let indexPath = indexPath, let newIndexPath = newIndexPath else { return } 90 | tableView?.moveRow(at: indexPath, to: newIndexPath) 91 | } 92 | } 93 | 94 | func controllerDidChangeContent(_ controller: NSFetchedResultsController) { 95 | tableView?.endUpdates() 96 | } 97 | 98 | func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {} 99 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return nil } 100 | 101 | } 102 | 103 | -------------------------------------------------------------------------------- /Example/Sources/Class/ModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModelFactory.swift 3 | // CloudCoreExample 4 | // 5 | // Created by Vasily Ulianov on 13/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fakery 11 | import CoreData 12 | 13 | class ModelFactory { 14 | 15 | private static let faker: Faker = { 16 | let locale = Locale.preferredLanguages.first ?? "en" 17 | return Faker(locale: locale) 18 | }() 19 | 20 | @discardableResult 21 | static func insertOrganizationWithEmployees(context: NSManagedObjectContext) -> Organization { 22 | let org = self.insertOrganization(context: context) 23 | org.sort = Int32(faker.number.randomInt(min: 1, max: 1000)) 24 | 25 | for _ in 0...faker.number.randomInt(min: 0, max: 3) { 26 | let user = self.insertEmployee(context: context) 27 | user.organization = org 28 | } 29 | 30 | return org 31 | } 32 | 33 | // MARK: - Private methods 34 | 35 | private static func insertOrganization(context: NSManagedObjectContext) -> Organization { 36 | let org = Organization(context: context) 37 | org.name = faker.company.name() 38 | org.bs = faker.company.bs() 39 | org.founded = Date(timeIntervalSince1970: faker.number.randomDouble(min: 1292250324, max: 1513175137)) 40 | 41 | return org 42 | } 43 | 44 | static func insertEmployee(context: NSManagedObjectContext) -> Employee { 45 | let user = Employee(context: context) 46 | user.department = faker.commerce.department() 47 | user.name = faker.name.name() 48 | user.workingSince = Date(timeIntervalSince1970: faker.number.randomDouble(min: 661109847, max: 1513186653)) 49 | user.photoData = randomAvatar() 50 | 51 | return user 52 | } 53 | 54 | private static func randomAvatar() -> Data? { 55 | let randomNumber = String(faker.number.randomInt(min: 1, max: 9)) 56 | let image = UIImage(named: "avatar_" + randomNumber)! 57 | return UIImagePNGRepresentation(image) 58 | } 59 | 60 | static func newCompanyName() -> String { 61 | return faker.company.name() 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Example/Sources/Class/NotificationsObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsObserver.swift 3 | // CloudCoreExample 4 | // 5 | // Created by Vasily Ulianov on 13/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudCore 11 | import os.log 12 | 13 | class CloudCoreDelegateHandler: CloudCoreDelegate { 14 | 15 | func willSyncFromCloud() { 16 | os_log("🔁 Started fetching from iCloud", log: OSLog.default, type: .debug) 17 | } 18 | 19 | func didSyncFromCloud() { 20 | os_log("✅ Finishing fetching from iCloud", log: OSLog.default, type: .debug) 21 | } 22 | 23 | func willSyncToCloud() { 24 | os_log("💾 Started saving to iCloud", log: OSLog.default, type: .debug) 25 | } 26 | 27 | func didSyncToCloud() { 28 | os_log("✅ Finished saving to iCloud", log: OSLog.default, type: .debug) 29 | } 30 | 31 | func error(error: Error, module: Module?) { 32 | print("⚠️ CloudCore error detected in module \(String(describing: module)): \(error)") 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Example/Sources/View Controller/DetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailViewController.swift 3 | // CloudTest2 4 | // 5 | // Created by Vasily Ulianov on 14.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import CoreData 11 | 12 | class DetailViewController: UITableViewController { 13 | 14 | var organizationID: NSManagedObjectID! 15 | let context = persistentContainer.viewContext 16 | 17 | private var tableDataSource: DetailTableDataSource! 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | 22 | let fetchRequest: NSFetchRequest = Employee.fetchRequest() 23 | fetchRequest.predicate = NSPredicate(format: "organization == %@", organizationID) 24 | 25 | fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] 26 | 27 | tableDataSource = DetailTableDataSource(fetchRequest: fetchRequest, context: context, sectionNameKeyPath: nil, delegate: self, tableView: tableView) 28 | tableView.dataSource = tableDataSource 29 | try! tableDataSource.performFetch() 30 | 31 | navigationItem.rightBarButtonItem = editButtonItem 32 | } 33 | 34 | override func setEditing(_ editing: Bool, animated: Bool) { 35 | super.setEditing(editing, animated: animated) 36 | 37 | if editing { 38 | let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(navAddButtonDidTap(_:))) 39 | navigationItem.setLeftBarButton(addButton, animated: animated) 40 | 41 | let renameButton = UIBarButtonItem(title: "Rename", style: .plain, target: self, action: #selector(navRenameButtonDidTap(_:))) 42 | navigationItem.setRightBarButtonItems([editButtonItem, renameButton], animated: animated) 43 | } else { 44 | navigationItem.setLeftBarButton(nil, animated: animated) 45 | navigationItem.setRightBarButtonItems([editButtonItem], animated: animated) 46 | try! context.save() 47 | } 48 | } 49 | 50 | @objc private func navAddButtonDidTap(_ sender: UIBarButtonItem) { 51 | let employee = ModelFactory.insertEmployee(context: context) 52 | let organization = context.object(with: organizationID) as! Organization 53 | employee.organization = organization 54 | } 55 | 56 | @objc private func navRenameButtonDidTap(_ sender: UIBarButtonItem) { 57 | let organization = context.object(with: organizationID) as! Organization 58 | organization.name = ModelFactory.newCompanyName() 59 | self.title = organization.name 60 | } 61 | 62 | } 63 | 64 | extension DetailViewController: FRCTableViewDelegate { 65 | 66 | func frcTableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 67 | let cell = tableView.dequeueReusableCell(withIdentifier: "Employee", for: indexPath) as! EmployeeTableViewCell 68 | let employee = tableDataSource.object(at: indexPath) 69 | 70 | cell.nameLabel.text = employee.name 71 | 72 | if let imageData = employee.photoData, let image = UIImage(data: imageData) { 73 | cell.photoImageView.image = image 74 | } else { 75 | cell.photoImageView.image = nil 76 | } 77 | 78 | var departmentText = employee.department ?? "No" 79 | departmentText += " department" 80 | cell.departmentLabel.text = departmentText 81 | 82 | var miniText = "Since " 83 | if let workingSince = employee.workingSince { 84 | miniText += DateFormatter.localizedString(from: workingSince, dateStyle: .medium, timeStyle: .none) 85 | } else { 86 | miniText += "unknown date" 87 | } 88 | 89 | cell.sinceLabel.text = miniText 90 | 91 | return cell 92 | } 93 | 94 | } 95 | 96 | fileprivate class DetailTableDataSource: FRCTableViewDataSource { 97 | 98 | override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { 99 | let context = frc.managedObjectContext 100 | 101 | switch editingStyle { 102 | case .delete: context.delete(object(at: indexPath)) 103 | default: return 104 | } 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /Example/Sources/View Controller/MasterViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MasterViewController.swift 3 | // CloudTest2 4 | // 5 | // Created by Vasily Ulianov on 14.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import CoreData 11 | import CloudCore 12 | 13 | class MasterViewController: UITableViewController { 14 | 15 | private var tableDataSource: MasterTableViewDataSource! 16 | 17 | var mockAddNewOrganization: Organization? 18 | let context = persistentContainer.viewContext 19 | 20 | // MARK: - UIViewController methods 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | 25 | let fetchRequest: NSFetchRequest = Organization.fetchRequest() 26 | fetchRequest.sortDescriptors = [NSSortDescriptor(key: "sort", ascending: true)] 27 | tableDataSource = MasterTableViewDataSource(fetchRequest: fetchRequest, context: context, sectionNameKeyPath: nil, delegate: self, tableView: tableView) 28 | tableView.dataSource = tableDataSource 29 | try! tableDataSource.performFetch() 30 | 31 | self.clearsSelectionOnViewWillAppear = true 32 | 33 | self.navigationItem.rightBarButtonItem = editButtonItem 34 | } 35 | 36 | override func setEditing(_ editing: Bool, animated: Bool) { 37 | super.setEditing(editing, animated: animated) 38 | 39 | // Save on editing end 40 | if !editing { 41 | try! context.save() 42 | } 43 | } 44 | 45 | @IBAction func addButtonClicked(_ sender: UIBarButtonItem) { 46 | ModelFactory.insertOrganizationWithEmployees(context: context) 47 | try! context.save() 48 | } 49 | 50 | @IBAction func refreshValueChanged(_ sender: UIRefreshControl) { 51 | CloudCore.fetchAndSave(to: persistentContainer, error: { (error) in 52 | print("⚠️ FetchAndSave error: \(error)") 53 | DispatchQueue.main.async { 54 | sender.endRefreshing() 55 | } 56 | }) { 57 | DispatchQueue.main.async { 58 | sender.endRefreshing() 59 | } 60 | } 61 | } 62 | 63 | // MARK: - Segues 64 | 65 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 66 | if let cell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: cell), let detailVC = segue.destination as? DetailViewController { 67 | let organization = tableDataSource.object(at: indexPath) 68 | detailVC.organizationID = organization.objectID 69 | detailVC.title = organization.name 70 | } 71 | } 72 | 73 | override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCellEditingStyle { 74 | return .delete 75 | } 76 | 77 | } 78 | 79 | extension MasterViewController: FRCTableViewDelegate { 80 | 81 | func frcTableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 82 | let cell = tableView.dequeueReusableCell(withIdentifier: "RightDetail", for: indexPath) 83 | let organization = tableDataSource.object(at: indexPath) 84 | 85 | cell.textLabel?.text = organization.name 86 | 87 | let employeesCount = organization.employees?.count ?? 0 88 | cell.detailTextLabel?.text = String(employeesCount) + " employees" 89 | 90 | return cell 91 | } 92 | 93 | } 94 | 95 | fileprivate class MasterTableViewDataSource: FRCTableViewDataSource { 96 | 97 | override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { 98 | let context = frc.managedObjectContext 99 | 100 | switch editingStyle { 101 | case .delete: context.delete(object(at: indexPath)) 102 | default: return 103 | } 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /Example/Sources/View/EmployeeTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmployeeTableViewCell.swift 3 | // CloudCoreExample 4 | // 5 | // Created by Vasily Ulianov on 13/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class EmployeeTableViewCell: UITableViewCell { 12 | 13 | @IBOutlet weak var photoImageView: UIImageView! 14 | @IBOutlet weak var nameLabel: UILabel! 15 | @IBOutlet weak var departmentLabel: UILabel! 16 | @IBOutlet weak var sinceLabel: UILabel! 17 | 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Vasily Ulianov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived 2 | 3 | Nowdays repository is not actively maintained, so I put that repo in an archived state because I can't provide high-quality support and actual updates for that project. Maybe a little later I could take control of that and push a new version of CloudCore. 4 | 5 | I recommend looking at [Deeje's pull request](https://github.com/Sorix/CloudCore/pull/51), he has implemented a lot of fixes and new features, but it's not my code so I couldn't guarantee anything. 6 | 7 | # CloudCore 8 | 9 | [![Documentation](https://sorix.github.io/CloudCore/badge.svg)](https://sorix.github.io/CloudCore/) 10 | [![Version](https://img.shields.io/cocoapods/v/CloudCore.svg?style=flat)](https://cocoapods.org/pods/CloudCore) 11 | ![Platform](https://img.shields.io/cocoapods/p/CloudCore.svg?style=flat) 12 | ![Status](https://img.shields.io/badge/status-beta-orange.svg) 13 | ![Swift](https://img.shields.io/badge/swift-4-orange.svg) 14 | 15 | **CloudCore** is a framework that manages syncing between iCloud (CloudKit) and Core Data written on native Swift. It maybe used are CloudKit caching. 16 | 17 | #### Features 18 | * Sync manually or on **push notifications**. 19 | * **Differential sync**, only changed object and values are uploaded and downloaded. CloudCore even differs changed and not changed values inside objects. 20 | * Respects of Core Data options (cascade deletions, external storage). 21 | * Knows and manages with CloudKit errors like `userDeletedZone`, `zoneNotFound`, `changeTokenExpired`, `isMore`. 22 | * Covered with Unit and CloudKit online **tests**. 23 | * All public methods are **[100% documented](https://sorix.github.io/CloudCore/)**. 24 | * Currently only **private database** is supported. 25 | 26 | ## How it works? 27 | 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. 28 | 29 | 1. CloudCore stores *change tokens* from CloudKit, so only changed data is downloaded. 30 | 2. When CloudCore is enabled (`CloudCore.enable`) it fetches changed data from CloudKit and subscribes to CloudKit push notifications about new changes. 31 | 3. When `CloudCore.fetchAndSave` is called manually or by push notification, CloudCore fetches and saves changed data to Core Data. 32 | 4. When data is written to persistent container (parent context is saved) CloudCore founds locally changed data and uploads it to CloudKit. 33 | 34 | ## Installation 35 | 36 | ### CocoaPods 37 | **CloudCore** is available through [CocoaPods](http://cocoapods.org). To install 38 | it, simply add the following line to your Podfile: 39 | 40 | ```ruby 41 | pod 'CloudCore', '~> 2.0' 42 | ``` 43 | 44 | ## How to help? 45 | 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). 46 | 47 | ## Documentation 48 | 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. 49 | HTML-generated version of that documentation is [**available here**](https://sorix.github.io/CloudCore/). 50 | 51 | ## Quick start 52 | 1. Enable CloudKit capability for you application: 53 | ![CloudKit capability](https://cloud.githubusercontent.com/assets/5610904/25092841/28305bc0-2398-11e7-9fbf-f94c619c264f.png) 54 | 55 | 2. Add 2 service attributes to each entity in CoreData model you want to sync: 56 | * `recordData` attribute with `Binary` type 57 | * `recordID` attribute with `String` type 58 | 59 | 3. Make changes in your **AppDelegate.swift** file: 60 | 61 | ```swift 62 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 63 | // Register for push notifications about changes 64 | application.registerForRemoteNotifications() 65 | 66 | // Enable CloudCore syncing 67 | CloudCore.enable(persistentContainer: persistentContainer) 68 | 69 | return true 70 | } 71 | 72 | // Notification from CloudKit about changes in remote database 73 | func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { 74 | // Check if it CloudKit's and CloudCore notification 75 | if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { 76 | // Fetch changed data from iCloud 77 | CloudCore.fetchAndSave(using: userInfo, to: persistentContainer, error: nil, completion: { (fetchResult) in 78 | completionHandler(fetchResult.uiBackgroundFetchResult) 79 | }) 80 | } 81 | } 82 | 83 | func applicationWillTerminate(_ application: UIApplication) { 84 | // Save tokens on exit used to differential sync 85 | CloudCore.tokens.saveToUserDefaults() 86 | } 87 | ``` 88 | 89 | 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. 90 | 91 | ## Service attributes 92 | 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. 93 | 94 | Required attributes for each synced entity: 95 | 1. *Record Data* attribute with `Binary` type 96 | 2. *Record ID* attribute with `String` type 97 | 98 | You may specify attributes' names in 2 ways (you may combine that ways in different entities). 99 | 100 | ### User Info 101 | 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: 102 | * *Record Data* value is `recordData`. 103 | * *Record ID* value is `recordID`. 104 | 105 | ![Model editor User Info](https://cloud.githubusercontent.com/assets/5610904/24004400/52e0ff94-0a77-11e7-9dd9-e1e24a86add5.png) 106 | 107 | ### Default names 108 | The most simple way is to name attributes with default names because you don't need to specify any User Info. 109 | 110 | ### 💡 Tips 111 | * You can name attribute as you want, value of User Info is not changed (you can create attribute `myid` with User Info: `CloudCoreType: recordID`) 112 | * I recommend to mark *Record ID* attribute as `Indexed`, that can speed up updates in big databases. 113 | * *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. 114 | 115 | ## Example application 116 | You can find example application at [Example](/Example/) directory. 117 | 118 | **How to run it:** 119 | 1. Set Bundle Identifier. 120 | 2. Check that embedded binaries has a correct path (you can remove and add again CloudCore.framework). 121 | 3. If you're using simulator, login at iCloud on it. 122 | 123 | **How to use it:** 124 | * **+** button adds new object to local storage (that will be automatically synced to Cloud) 125 | * **refresh** button calls `fetchAndSave` to fetch data from Cloud. That is useful button for simulators because Simulator unable to receive push notifications 126 | * 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. 127 | 128 | ## Tests 129 | CloudKit objects can't be mocked up, that's why I create 2 different types of tests: 130 | 131 | * `Tests/Unit` here I placed tests that can be performed without CloudKit connection. That tests are executed when you submit a Pull Request. 132 | * `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. 133 | 134 | Nothing will be wrong with your account, tests use only private `CKDatabase` for application. 135 | 136 | **Please run these tests before opening pull requests.** 137 | To run them you need to: 138 | 1. Change `TestableApp` bundle id. 139 | 2. Run in simulator or real device `TestableApp` target. 140 | 3. Configure iCloud on that device: Settings.app → iCloud → Login. 141 | 4. Run `CloudKitTests`, they are attached to `TestableApp`, so CloudKit connection will work. 142 | 143 | ## Roadmap 144 | 145 | - [x] Move from alpha to beta status. 146 | - [ ] Add `CloudCore.disable` method 147 | - [ ] Add methods to clear local cache and remote database 148 | - [ ] Add error resolving for `limitExceeded` error (split saves by relationships). 149 | 150 | ## Author 151 | 152 | Open for hire / relocation. 153 | Vasily Ulianov, [va...@me.com](http://www.google.com/recaptcha/mailhide/d?k=01eFEpy-HM-qd0Vf6QGABTjw==&c=JrKKY2bjm0Bp58w7zTvPiQ==) 154 | -------------------------------------------------------------------------------- /Source/Classes/AsynchronousOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsynchronousOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 09.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Subclass of `Operation` that add support of asynchronous operations. 12 | /// ## How to use: 13 | /// 1. Call `super.main()` when override `main` method, call `super.start()` when override `start` method. 14 | /// 2. When operation is finished or cancelled set `self.state = .finished` 15 | class AsynchronousOperation: Operation { 16 | open override var isAsynchronous: Bool { return true } 17 | open override var isExecuting: Bool { return state == .executing } 18 | open override var isFinished: Bool { return state == .finished } 19 | 20 | public var state = State.ready { 21 | willSet { 22 | willChangeValue(forKey: state.keyPath) 23 | willChangeValue(forKey: newValue.keyPath) 24 | } 25 | didSet { 26 | didChangeValue(forKey: state.keyPath) 27 | didChangeValue(forKey: oldValue.keyPath) 28 | } 29 | } 30 | 31 | enum State: String { 32 | case ready = "Ready" 33 | case executing = "Executing" 34 | case finished = "Finished" 35 | fileprivate var keyPath: String { return "is" + self.rawValue } 36 | } 37 | 38 | override func start() { 39 | if self.isCancelled { 40 | state = .finished 41 | } else { 42 | state = .ready 43 | main() 44 | } 45 | } 46 | 47 | override func main() { 48 | if self.isCancelled { 49 | state = .finished 50 | } else { 51 | state = .executing 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Source/Classes/CloudCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudCore.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 06.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | /** 13 | Main framework class, in most cases you will use only methods from this class, all methods and properties are `static`. 14 | 15 | ## Save to cloud 16 | On application inialization call `CloudCore.enable(persistentContainer:)` method, so framework will automatically monitor changes at Core Data and upload it to iCloud. 17 | 18 | ### Example 19 | ```swift 20 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 21 | // Register for push notifications about changes 22 | application.registerForRemoteNotifications() 23 | 24 | // Enable CloudCore syncing 25 | CloudCore.delegate = someDelegate // it is recommended to set delegate to track errors 26 | CloudCore.enable(persistentContainer: persistentContainer) 27 | 28 | return true 29 | } 30 | ``` 31 | 32 | ## Fetch from cloud 33 | 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. 34 | 35 | ### Example 36 | ```swift 37 | func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { 38 | // Check if it CloudKit's and CloudCore notification 39 | if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { 40 | // Fetch changed data from iCloud 41 | CloudCore.fetchAndSave(using: userInfo, to: persistentContainer, error: nil, completion: { (fetchResult) in 42 | completionHandler(fetchResult.uiBackgroundFetchResult) 43 | }) 44 | } 45 | } 46 | ``` 47 | 48 | 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:)` 49 | */ 50 | open class CloudCore { 51 | 52 | // MARK: - Properties 53 | 54 | private(set) static var coreDataListener: CoreDataListener? 55 | 56 | /// CloudCore configuration, it's recommended to set up before calling any of CloudCore methods. You can read more at `CloudCoreConfig` struct description 57 | public static var config = CloudCoreConfig() 58 | 59 | /// `Tokens` object, read more at class description. By default variable is loaded from User Defaults. 60 | public static var tokens = Tokens.loadFromUserDefaults() 61 | 62 | /// Error and sync actions are reported to that delegate 63 | public static weak var delegate: CloudCoreDelegate? { 64 | didSet { 65 | coreDataListener?.delegate = delegate 66 | } 67 | } 68 | 69 | public typealias NotificationUserInfo = [AnyHashable : Any] 70 | 71 | static private let queue = OperationQueue() 72 | 73 | // MARK: - Methods 74 | 75 | /// Enable CloudKit and Core Data synchronization 76 | /// 77 | /// - Parameters: 78 | /// - container: `NSPersistentContainer` that will be used to save data 79 | public static func enable(persistentContainer container: NSPersistentContainer) { 80 | // Listen for local changes 81 | let listener = CoreDataListener(container: container) 82 | listener.delegate = self.delegate 83 | listener.observe() 84 | self.coreDataListener = listener 85 | 86 | // Subscribe (subscription may be outdated/removed) 87 | #if !os(watchOS) 88 | let subscribeOperation = SubscribeOperation() 89 | subscribeOperation.errorBlock = { handle(subscriptionError: $0, container: container) } 90 | queue.addOperation(subscribeOperation) 91 | #endif 92 | 93 | // Fetch updated data (e.g. push notifications weren't received) 94 | let updateFromCloudOperation = FetchAndSaveOperation(persistentContainer: container) 95 | updateFromCloudOperation.errorBlock = { 96 | self.delegate?.error(error: $0, module: .some(.fetchFromCloud)) 97 | } 98 | 99 | #if !os(watchOS) 100 | updateFromCloudOperation.addDependency(subscribeOperation) 101 | #endif 102 | 103 | queue.addOperation(updateFromCloudOperation) 104 | } 105 | 106 | /// Disables synchronization (push notifications won't be sent also) 107 | public static func disable() { 108 | queue.cancelAllOperations() 109 | 110 | coreDataListener?.stopObserving() 111 | coreDataListener = nil 112 | 113 | // FIXME: unsubscribe 114 | } 115 | 116 | // MARK: Fetchers 117 | 118 | /** Fetch changes from one CloudKit database and save it to CoreData from `didReceiveRemoteNotification` method. 119 | 120 | 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. 121 | 122 | - Parameters: 123 | - userInfo: notification's user info, CloudKit database will be extraced from that notification 124 | - container: `NSPersistentContainer` that will be used to save fetched data 125 | - error: block will be called every time when error occurs during process 126 | - completion: `FetchResult` enumeration with results of operation 127 | */ 128 | public static func fetchAndSave(using userInfo: NotificationUserInfo, to container: NSPersistentContainer, error: ErrorBlock?, completion: @escaping (_ fetchResult: FetchResult) -> Void) { 129 | guard let cloudDatabase = self.database(for: userInfo) else { 130 | completion(.noData) 131 | return 132 | } 133 | 134 | DispatchQueue.global(qos: .utility).async { 135 | let errorProxy = ErrorBlockProxy(destination: error) 136 | let operation = FetchAndSaveOperation(from: [cloudDatabase], persistentContainer: container) 137 | operation.errorBlock = { errorProxy.send(error: $0) } 138 | operation.start() 139 | 140 | if errorProxy.wasError { 141 | completion(FetchResult.failed) 142 | } else { 143 | completion(FetchResult.newData) 144 | } 145 | } 146 | } 147 | 148 | /** Fetch changes from all CloudKit databases and save it to Core Data 149 | 150 | - Parameters: 151 | - container: `NSPersistentContainer` that will be used to save fetched data 152 | - error: block will be called every time when error occurs during process 153 | - completion: `FetchResult` enumeration with results of operation 154 | */ 155 | public static func fetchAndSave(to container: NSPersistentContainer, error: ErrorBlock?, completion: (() -> Void)?) { 156 | let operation = FetchAndSaveOperation(persistentContainer: container) 157 | operation.errorBlock = error 158 | operation.completionBlock = completion 159 | 160 | queue.addOperation(operation) 161 | } 162 | 163 | /** Check if notification is CloudKit notification containing CloudCore data 164 | 165 | - Parameter userInfo: userInfo of notification 166 | - Returns: `true` if notification contains CloudCore data 167 | */ 168 | public static func isCloudCoreNotification(withUserInfo userInfo: NotificationUserInfo) -> Bool { 169 | return (database(for: userInfo) != nil) 170 | } 171 | 172 | static func database(for notificationUserInfo: NotificationUserInfo) -> CKDatabase? { 173 | guard let notificationDictionary = notificationUserInfo as? [String: NSObject] else { return nil } 174 | let notification = CKNotification(fromRemoteNotificationDictionary: notificationDictionary) 175 | 176 | guard let id = notification.subscriptionID else { return nil } 177 | 178 | switch id { 179 | case config.subscriptionIDForPrivateDB: return config.container.privateCloudDatabase 180 | case config.subscriptionIDForSharedDB: return config.container.sharedCloudDatabase 181 | case _ where id.hasPrefix(config.publicSubscriptionIDPrefix): return config.container.publicCloudDatabase 182 | default: return nil 183 | } 184 | } 185 | 186 | static private func handle(subscriptionError: Error, container: NSPersistentContainer) { 187 | guard let cloudError = subscriptionError as? CKError, let partialErrorValues = cloudError.partialErrorsByItemID?.values else { 188 | delegate?.error(error: subscriptionError, module: nil) 189 | return 190 | } 191 | 192 | // Try to find "Zone Not Found" in partial errors 193 | for subError in partialErrorValues { 194 | guard let subError = subError as? CKError else { continue } 195 | 196 | if case .zoneNotFound = subError.code { 197 | // Zone wasn't found, we need to create it 198 | self.queue.cancelAllOperations() 199 | let setupOperation = SetupOperation(container: container, parentContext: nil) 200 | self.queue.addOperation(setupOperation) 201 | 202 | return 203 | } 204 | } 205 | 206 | delegate?.error(error: subscriptionError, module: nil) 207 | } 208 | 209 | } 210 | -------------------------------------------------------------------------------- /Source/Classes/ErrorBlockProxy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorBlockProxy.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 12.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Use that class to log if any errors were sent 12 | class ErrorBlockProxy { 13 | private(set) var wasError = false 14 | var destination: ErrorBlock? 15 | 16 | init(destination: ErrorBlock?) { 17 | self.destination = destination 18 | } 19 | 20 | func send(error: Error?) { 21 | if let error = error { 22 | self.wasError = true 23 | destination?(error) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Source/Classes/Fetch/FetchAndSaveOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchAndSaveOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 13/03/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | import CoreData 11 | 12 | /// 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` 13 | public class FetchAndSaveOperation: Operation { 14 | 15 | /// Private cloud database for the CKContainer specified by CloudCoreConfig 16 | public static let allDatabases = [ 17 | // CloudCore.config.container.publicCloudDatabase, 18 | CloudCore.config.container.privateCloudDatabase 19 | // CloudCore.config.container.sharedCloudDatabase 20 | ] 21 | 22 | public typealias NotificationUserInfo = [AnyHashable : Any] 23 | 24 | private let tokens: Tokens 25 | private let databases: [CKDatabase] 26 | private let persistentContainer: NSPersistentContainer 27 | 28 | /// Called every time if error occurs 29 | public var errorBlock: ErrorBlock? 30 | 31 | private let queue = OperationQueue() 32 | 33 | /// Initialize operation, it's recommended to set `errorBlock` 34 | /// 35 | /// - Parameters: 36 | /// - databases: list of databases to fetch data from (only private is supported now) 37 | /// - persistentContainer: `NSPersistentContainer` that will be used to save data 38 | /// - tokens: previously saved `Tokens`, you can generate new ones if you want to fetch all data 39 | public init(from databases: [CKDatabase] = FetchAndSaveOperation.allDatabases, persistentContainer: NSPersistentContainer, tokens: Tokens = CloudCore.tokens) { 40 | self.tokens = tokens 41 | self.databases = databases 42 | self.persistentContainer = persistentContainer 43 | 44 | queue.name = "FetchAndSaveQueue" 45 | } 46 | 47 | /// Performs the receiver’s non-concurrent task. 48 | override public func main() { 49 | if self.isCancelled { return } 50 | 51 | CloudCore.delegate?.willSyncFromCloud() 52 | 53 | let backgroundContext = persistentContainer.newBackgroundContext() 54 | backgroundContext.name = CloudCore.config.contextName 55 | 56 | for database in self.databases { 57 | self.addRecordZoneChangesOperation(recordZoneIDs: [CloudCore.config.zoneID], database: database, context: backgroundContext) 58 | } 59 | 60 | self.queue.waitUntilAllOperationsAreFinished() 61 | 62 | do { 63 | try backgroundContext.save() 64 | } catch { 65 | errorBlock?(error) 66 | } 67 | 68 | CloudCore.delegate?.didSyncFromCloud() 69 | } 70 | 71 | private func addRecordZoneChangesOperation(recordZoneIDs: [CKRecordZoneID], database: CKDatabase, context: NSManagedObjectContext) { 72 | if recordZoneIDs.isEmpty { return } 73 | 74 | let recordZoneChangesOperation = FetchRecordZoneChangesOperation(from: database, recordZoneIDs: recordZoneIDs, tokens: tokens) 75 | 76 | recordZoneChangesOperation.recordChangedBlock = { 77 | // Convert and write CKRecord To NSManagedObject Operation 78 | let convertOperation = RecordToCoreDataOperation(parentContext: context, record: $0) 79 | convertOperation.errorBlock = { self.errorBlock?($0) } 80 | self.queue.addOperation(convertOperation) 81 | } 82 | 83 | recordZoneChangesOperation.recordWithIDWasDeletedBlock = { 84 | // Delete NSManagedObject with specified recordID Operation 85 | let deleteOperation = DeleteFromCoreDataOperation(parentContext: context, recordID: $0) 86 | deleteOperation.errorBlock = { self.errorBlock?($0) } 87 | self.queue.addOperation(deleteOperation) 88 | } 89 | 90 | recordZoneChangesOperation.errorBlock = { zoneID, error in 91 | self.handle(recordZoneChangesError: error, in: zoneID, database: database, context: context) 92 | } 93 | 94 | queue.addOperation(recordZoneChangesOperation) 95 | } 96 | 97 | private func handle(recordZoneChangesError: Error, in zoneId: CKRecordZoneID, database: CKDatabase, context: NSManagedObjectContext) { 98 | guard let cloudError = recordZoneChangesError as? CKError else { 99 | errorBlock?(recordZoneChangesError) 100 | return 101 | } 102 | 103 | switch cloudError.code { 104 | // User purged cloud database, we need to delete local cache (according Apple Guidelines) 105 | case .userDeletedZone: 106 | queue.cancelAllOperations() 107 | 108 | let purgeOperation = PurgeLocalDatabaseOperation(parentContext: context, managedObjectModel: persistentContainer.managedObjectModel) 109 | purgeOperation.errorBlock = errorBlock 110 | queue.addOperation(purgeOperation) 111 | 112 | // Our token is expired, we need to refetch everything again 113 | case .changeTokenExpired: 114 | tokens.tokensByRecordZoneID[zoneId] = nil 115 | self.addRecordZoneChangesOperation(recordZoneIDs: [CloudCore.config.zoneID], database: database, context: context) 116 | default: errorBlock?(cloudError) 117 | } 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /Source/Classes/Fetch/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchPublicSubscriptionsOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 14/03/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | /// Fetch CloudCore's subscriptions from Public CKDatabase 12 | // TODO: Add Public support in future versions 13 | //class FetchPublicSubscriptionsOperation: AsynchronousOperation { 14 | // var errorBlock: ErrorBlock? 15 | // var fetchCompletionBlock: (([CKSubscription]) -> Void)? 16 | // 17 | // private let prefix = CloudCore.config.publicSubscriptionIDPrefix 18 | // 19 | // override func main() { 20 | // super.main() 21 | // 22 | // CKContainer.default().publicCloudDatabase.fetchAllSubscriptions { (subscriptions, error) in 23 | // defer { 24 | // self.state = .finished 25 | // } 26 | // 27 | // if let error = error { 28 | // self.errorBlock?(error) 29 | // return 30 | // } 31 | // 32 | // guard let subscriptions = subscriptions else { 33 | // self.fetchCompletionBlock?([CKSubscription]()) 34 | // return 35 | // } 36 | // 37 | // var cloudCoreSubscriptions = [CKSubscription]() 38 | // for subscription in subscriptions { 39 | // if !subscription.subscriptionID.hasPrefix(self.prefix) { continue } 40 | // cloudCoreSubscriptions.append(subscription) 41 | // } 42 | // 43 | // self.fetchCompletionBlock?(cloudCoreSubscriptions) 44 | // } 45 | // } 46 | //} 47 | -------------------------------------------------------------------------------- /Source/Classes/Fetch/PublicSubscriptions/PublicDatabaseSubscriptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PublicDatabaseSubscriptions.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 13/03/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | // TODO: Temporarily disabled, in development 12 | 13 | /// Use that class to manage subscriptions to public CloudKit database. 14 | /// 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. 15 | //class PublicDatabaseSubscriptions { 16 | // 17 | // private static var userDefaultsKey: String { return CloudCore.config.userDefaultsKeyTokens } 18 | // private static var prefix: String { return CloudCore.config.publicSubscriptionIDPrefix } 19 | // 20 | // internal(set) static var cachedIDs = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? [String]() 21 | // 22 | // /// Create `CKQuerySubscription` for public database, use it if you want to enable syncing public iCloud -> Core Data 23 | // /// 24 | // /// - Parameters: 25 | // /// - 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. 26 | // /// - 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). 27 | // /// - completion: returns subscriptionID and error upon operation completion 28 | // static func subscribe(recordType: String, predicate: NSPredicate, completion: ((_ subscriptionID: String, _ error: Error?) -> Void)?) { 29 | // let id = prefix + UUID().uuidString 30 | // let subscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: id, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]) 31 | // 32 | // let notificationInfo = CKNotificationInfo() 33 | // notificationInfo.shouldSendContentAvailable = true 34 | // subscription.notificationInfo = notificationInfo 35 | // 36 | // let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) 37 | // operation.modifySubscriptionsCompletionBlock = { _, _, error in 38 | // if error == nil { 39 | // self.cachedIDs.append(subscription.subscriptionID) 40 | // UserDefaults.standard.set(self.cachedIDs, forKey: self.userDefaultsKey) 41 | // UserDefaults.standard.synchronize() 42 | // } 43 | // 44 | // completion?(subscription.subscriptionID, error) 45 | // } 46 | // 47 | // operation.timeoutIntervalForResource = 20 48 | // CKContainer.default().publicCloudDatabase.add(operation) 49 | // } 50 | // 51 | // /// Unsubscribe from public database 52 | // /// 53 | // /// - Parameters: 54 | // /// - subscriptionID: id of subscription to remove 55 | // static func unsubscribe(subscriptionID: String, completion: ((Error?) -> Void)?) { 56 | // let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [], subscriptionIDsToDelete: [subscriptionID]) 57 | // operation.modifySubscriptionsCompletionBlock = { _, _, error in 58 | // if error == nil { 59 | // if let index = self.cachedIDs.index(of: subscriptionID) { 60 | // self.cachedIDs.remove(at: index) 61 | // } 62 | // UserDefaults.standard.set(self.cachedIDs, forKey: self.userDefaultsKey) 63 | // UserDefaults.standard.synchronize() 64 | // } 65 | // 66 | // completion?(error) 67 | // } 68 | // 69 | // operation.timeoutIntervalForResource = 20 70 | // CKContainer.default().publicCloudDatabase.add(operation) 71 | // } 72 | // 73 | // 74 | // /// Refresh local `cachedIDs` variable with actual data from CloudKit. 75 | // /// Recommended to use after application's UserDefaults reset. 76 | // /// 77 | // /// - Parameter completion: called upon operation completion, contains list of CloudCore subscriptions and error 78 | // static func refreshCache(errorCompletion: ErrorBlock? = nil, successCompletion: (([CKSubscription]) -> Void)? = nil) { 79 | // let operation = FetchPublicSubscriptionsOperation() 80 | // operation.errorBlock = errorCompletion 81 | // operation.fetchCompletionBlock = { subscriptions in 82 | // self.setCache(from: subscriptions) 83 | // successCompletion?(subscriptions) 84 | // } 85 | // operation.start() 86 | // } 87 | // 88 | // internal static func setCache(from subscriptions: [CKSubscription]) { 89 | // let ids = subscriptions.map { $0.subscriptionID } 90 | // self.cachedIDs = ids 91 | // 92 | // UserDefaults.standard.set(ids, forKey: self.userDefaultsKey) 93 | // UserDefaults.standard.synchronize() 94 | // } 95 | //} 96 | 97 | -------------------------------------------------------------------------------- /Source/Classes/Fetch/SubOperations/DeleteFromCoreDataOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteFromCoreDataOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 09.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | class DeleteFromCoreDataOperation: Operation { 13 | let parentContext: NSManagedObjectContext 14 | let recordID: CKRecordID 15 | var errorBlock: ErrorBlock? 16 | 17 | init(parentContext: NSManagedObjectContext, recordID: CKRecordID) { 18 | self.parentContext = parentContext 19 | self.recordID = recordID 20 | 21 | super.init() 22 | 23 | self.name = "DeleteFromCoreDataOperation" 24 | } 25 | 26 | override func main() { 27 | if self.isCancelled { return } 28 | 29 | let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 30 | childContext.parent = parentContext 31 | 32 | // Iterate through each entity to fetch and delete object with our recordData 33 | guard let entities = childContext.persistentStoreCoordinator?.managedObjectModel.entities else { return } 34 | for entity in entities { 35 | guard let serviceAttributeNames = entity.serviceAttributeNames else { continue } 36 | 37 | do { 38 | let deleted = try self.delete(entityName: serviceAttributeNames.entityName, 39 | attributeNames: serviceAttributeNames, 40 | in: childContext) 41 | 42 | // only 1 record with such recordData may exists, if delete we don't need to fetch other entities 43 | if deleted { break } 44 | } catch { 45 | self.errorBlock?(error) 46 | continue 47 | } 48 | } 49 | 50 | do { 51 | try childContext.save() 52 | } catch { 53 | self.errorBlock?(error) 54 | } 55 | } 56 | 57 | /// Delete NSManagedObject with specified recordData from entity 58 | /// 59 | /// - Returns: `true` if object is found and deleted, `false` is object is not found 60 | private func delete(entityName: String, attributeNames: ServiceAttributeNames, in context: NSManagedObjectContext) throws -> Bool { 61 | let fetchRequest = NSFetchRequest(entityName: entityName) 62 | fetchRequest.includesPropertyValues = false 63 | fetchRequest.predicate = NSPredicate(format: attributeNames.recordID + " = %@", recordID.encodedString) 64 | 65 | guard let objects = try context.fetch(fetchRequest) as? [NSManagedObject] else { return false } 66 | if objects.isEmpty { return false } 67 | 68 | for object in objects { 69 | context.delete(object) 70 | } 71 | 72 | return true 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /Source/Classes/Fetch/SubOperations/FetchRecordZoneChangesOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchRecordZoneChangesOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 09.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | class FetchRecordZoneChangesOperation: Operation { 12 | // Set on init 13 | let tokens: Tokens 14 | let recordZoneIDs: [CKRecordZoneID] 15 | let database: CKDatabase 16 | // 17 | 18 | var errorBlock: ((CKRecordZoneID, Error) -> Void)? 19 | var recordChangedBlock: ((CKRecord) -> Void)? 20 | var recordWithIDWasDeletedBlock: ((CKRecordID) -> Void)? 21 | 22 | private let optionsByRecordZoneID: [CKRecordZoneID: CKFetchRecordZoneChangesOptions] 23 | private let fetchQueue = OperationQueue() 24 | 25 | init(from database: CKDatabase, recordZoneIDs: [CKRecordZoneID], tokens: Tokens) { 26 | self.tokens = tokens 27 | self.database = database 28 | self.recordZoneIDs = recordZoneIDs 29 | 30 | var optionsByRecordZoneID = [CKRecordZoneID: CKFetchRecordZoneChangesOptions]() 31 | for zoneID in recordZoneIDs { 32 | let options = CKFetchRecordZoneChangesOptions() 33 | options.previousServerChangeToken = self.tokens.tokensByRecordZoneID[zoneID] 34 | optionsByRecordZoneID[zoneID] = options 35 | } 36 | self.optionsByRecordZoneID = optionsByRecordZoneID 37 | 38 | super.init() 39 | 40 | self.name = "FetchRecordZoneChangesOperation" 41 | } 42 | 43 | override func main() { 44 | super.main() 45 | 46 | let fetchOperation = self.makeFetchOperation(optionsByRecordZoneID: optionsByRecordZoneID) 47 | self.fetchQueue.addOperation(fetchOperation) 48 | 49 | fetchQueue.waitUntilAllOperationsAreFinished() 50 | } 51 | 52 | private func makeFetchOperation(optionsByRecordZoneID: [CKRecordZoneID: CKFetchRecordZoneChangesOptions]) -> CKFetchRecordZoneChangesOperation { 53 | // Init Fetch Operation 54 | let fetchOperation = CKFetchRecordZoneChangesOperation(recordZoneIDs: recordZoneIDs, optionsByRecordZoneID: optionsByRecordZoneID) 55 | 56 | fetchOperation.recordChangedBlock = { 57 | self.recordChangedBlock?($0) 58 | } 59 | fetchOperation.recordWithIDWasDeletedBlock = { recordID, _ in 60 | self.recordWithIDWasDeletedBlock?(recordID) 61 | } 62 | fetchOperation.recordZoneFetchCompletionBlock = { zoneId, serverChangeToken, clientChangeTokenData, isMore, error in 63 | self.tokens.tokensByRecordZoneID[zoneId] = serverChangeToken 64 | 65 | if let error = error { 66 | self.errorBlock?(zoneId, error) 67 | } 68 | 69 | if isMore { 70 | let moreOperation = self.makeFetchOperation(optionsByRecordZoneID: optionsByRecordZoneID) 71 | self.fetchQueue.addOperation(moreOperation) 72 | } 73 | } 74 | 75 | fetchOperation.qualityOfService = self.qualityOfService 76 | fetchOperation.database = self.database 77 | 78 | return fetchOperation 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Source/Classes/Fetch/SubOperations/PurgeLocalDatabaseOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurgeLocalDatabaseOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 12/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | class PurgeLocalDatabaseOperation: Operation { 12 | 13 | let parentContext: NSManagedObjectContext 14 | let managedObjectModel: NSManagedObjectModel 15 | var errorBlock: ErrorBlock? 16 | 17 | init(parentContext: NSManagedObjectContext, managedObjectModel: NSManagedObjectModel) { 18 | self.parentContext = parentContext 19 | self.managedObjectModel = managedObjectModel 20 | 21 | super.init() 22 | } 23 | 24 | override func main() { 25 | super.main() 26 | 27 | let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 28 | childContext.parent = parentContext 29 | 30 | for entity in managedObjectModel.cloudCoreEnabledEntities { 31 | guard let entityName = entity.name else { continue } 32 | 33 | let fetchRequest = NSFetchRequest(entityName: entityName) 34 | fetchRequest.includesPropertyValues = false 35 | 36 | do { 37 | // I don't user `NSBatchDeleteRequest` because we can't notify viewContextes about changes 38 | guard let objects = try childContext.fetch(fetchRequest) as? [NSManagedObject] else { continue } 39 | 40 | for object in objects { 41 | childContext.delete(object) 42 | } 43 | } catch { 44 | errorBlock?(error) 45 | } 46 | } 47 | 48 | do { 49 | try childContext.save() 50 | } catch { 51 | errorBlock?(error) 52 | } 53 | } 54 | 55 | 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Source/Classes/Fetch/SubOperations/RecordToCoreDataOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordToCoreDataOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 08.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | /// Convert CKRecord to NSManagedObject and save it to parent context, thread-safe 13 | class RecordToCoreDataOperation: AsynchronousOperation { 14 | let parentContext: NSManagedObjectContext 15 | let record: CKRecord 16 | var errorBlock: ErrorBlock? 17 | 18 | /// - Parameters: 19 | /// - parentContext: operation will be safely performed in that context, **operation doesn't save that context** you need to do it manually 20 | /// - record: record that will be converted to `NSManagedObject` 21 | init(parentContext: NSManagedObjectContext, record: CKRecord) { 22 | self.parentContext = parentContext 23 | self.record = record 24 | 25 | super.init() 26 | 27 | self.name = "RecordToCoreDataOperation" 28 | } 29 | 30 | override func main() { 31 | if self.isCancelled { return } 32 | 33 | parentContext.perform { 34 | do { 35 | try self.setManagedObject(in: self.parentContext) 36 | } catch { 37 | self.errorBlock?(error) 38 | } 39 | 40 | self.state = .finished 41 | } 42 | } 43 | 44 | /// Create or update existing NSManagedObject from CKRecord 45 | /// 46 | /// - Parameter context: child context to perform fetch operations 47 | private func setManagedObject(in context: NSManagedObjectContext) throws { 48 | let entityName = record.recordType 49 | 50 | guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { 51 | throw CloudCoreError.coreData("Unable to find entity specified in CKRecord: " + entityName) 52 | } 53 | guard let serviceAttributes = NSEntityDescription.entity(forEntityName: entityName, in: context)?.serviceAttributeNames else { 54 | throw CloudCoreError.missingServiceAttributes(entityName: entityName) 55 | } 56 | 57 | // Try to find existing objects 58 | let fetchRequest = NSFetchRequest(entityName: entityName) 59 | fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordID + " == %@", record.recordID.encodedString) 60 | 61 | if let foundObject = try context.fetch(fetchRequest).first as? NSManagedObject { 62 | try fill(object: foundObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) 63 | } else { 64 | let newObject = NSManagedObject(entity: entity, insertInto: context) 65 | try fill(object: newObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) 66 | } 67 | } 68 | 69 | 70 | /// Fill provided `NSManagedObject` with data 71 | /// 72 | /// - Parameters: 73 | /// - entityName: entity name of `object` 74 | /// - recordDataAttributeName: attribute name containing recordData 75 | private func fill(object: NSManagedObject, entityName: String, serviceAttributeNames: ServiceAttributeNames, context: NSManagedObjectContext) throws { 76 | for key in record.allKeys() { 77 | let recordValue = record.value(forKey: key) 78 | 79 | let attribute = CloudKitAttribute(value: recordValue, fieldName: key, entityName: entityName, serviceAttributes: serviceAttributeNames, context: context) 80 | let coreDataValue = try attribute.makeCoreDataValue() 81 | object.setValue(coreDataValue, forKey: key) 82 | } 83 | 84 | // Set system headers 85 | object.setValue(record.recordID.encodedString, forKey: serviceAttributeNames.recordID) 86 | object.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.recordData) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Source/Classes/Save/CloudSaveOperationQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudSaveController.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 06.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | import CoreData 11 | 12 | class CloudSaveOperationQueue: OperationQueue { 13 | var errorBlock: ErrorBlock? 14 | 15 | /// Modify CloudKit database, operations will be created and added to operation queue. 16 | func addOperations(recordsToSave: [RecordWithDatabase], recordIDsToDelete: [RecordIDWithDatabase]) { 17 | var datasource = [DatabaseModifyDataSource]() 18 | 19 | // Split records to save to databases 20 | for recordToSave in recordsToSave { 21 | if let modifier = datasource.find(database: recordToSave.database) { 22 | modifier.save.append(recordToSave.record) 23 | } else { 24 | let newModifier = DatabaseModifyDataSource(database: recordToSave.database) 25 | newModifier.save.append(recordToSave.record) 26 | datasource.append(newModifier) 27 | } 28 | } 29 | 30 | // Split record ids to delete to databases 31 | for idToDelete in recordIDsToDelete { 32 | if let modifier = datasource.find(database: idToDelete.database) { 33 | modifier.delete.append(idToDelete.recordID) 34 | } else { 35 | let newModifier = DatabaseModifyDataSource(database: idToDelete.database) 36 | newModifier.delete.append(idToDelete.recordID) 37 | datasource.append(newModifier) 38 | } 39 | } 40 | 41 | // Perform 42 | for databaseModifier in datasource { 43 | addOperation(recordsToSave: databaseModifier.save, recordIDsToDelete: databaseModifier.delete, database: databaseModifier.database) 44 | } 45 | } 46 | 47 | private func addOperation(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecordID], database: CKDatabase) { 48 | // Modify CKRecord Operation 49 | let modifyOperation = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) 50 | modifyOperation.savePolicy = .changedKeys 51 | 52 | modifyOperation.perRecordCompletionBlock = { record, error in 53 | if let error = error { 54 | self.errorBlock?(error) 55 | } else { 56 | self.removeCachedAssets(for: record) 57 | } 58 | } 59 | 60 | modifyOperation.modifyRecordsCompletionBlock = { _, _, error in 61 | if let error = error { 62 | self.errorBlock?(error) 63 | } 64 | } 65 | 66 | modifyOperation.database = database 67 | 68 | self.addOperation(modifyOperation) 69 | } 70 | 71 | /// Remove locally cached assets prepared for uploading at CloudKit 72 | private func removeCachedAssets(for record: CKRecord) { 73 | for key in record.allKeys() { 74 | guard let asset = record.value(forKey: key) as? CKAsset else { continue } 75 | try? FileManager.default.removeItem(at: asset.fileURL) 76 | } 77 | } 78 | 79 | } 80 | 81 | fileprivate class DatabaseModifyDataSource { 82 | let database: CKDatabase 83 | var save = [CKRecord]() 84 | var delete = [CKRecordID]() 85 | 86 | init(database: CKDatabase) { 87 | self.database = database 88 | } 89 | } 90 | 91 | extension Sequence where Iterator.Element == DatabaseModifyDataSource { 92 | func find(database: CKDatabase) -> DatabaseModifyDataSource? { 93 | for element in self { 94 | if element.database == database { return element } 95 | } 96 | 97 | return nil 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Source/Classes/Save/CoreDataListener.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataChangesListener.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | import CloudKit 12 | 13 | /// Class responsible for taking action on Core Data save notifications 14 | class CoreDataListener { 15 | var container: NSPersistentContainer 16 | 17 | let converter = ObjectToRecordConverter() 18 | let cloudSaveOperationQueue = CloudSaveOperationQueue() 19 | 20 | let cloudContextName = "CloudCoreSync" 21 | 22 | // Used for errors delegation 23 | weak var delegate: CloudCoreDelegate? 24 | 25 | public init(container: NSPersistentContainer) { 26 | self.container = container 27 | converter.errorBlock = { [weak self] in 28 | self?.delegate?.error(error: $0, module: .some(.saveToCloud)) 29 | } 30 | } 31 | 32 | /// Observe Core Data willSave and didSave notifications 33 | func observe() { 34 | NotificationCenter.default.addObserver(self, selector: #selector(self.willSave(notification:)), name: .NSManagedObjectContextWillSave, object: nil) 35 | NotificationCenter.default.addObserver(self, selector: #selector(self.didSave(notification:)), name: .NSManagedObjectContextDidSave, object: nil) 36 | } 37 | 38 | /// Remove Core Data observers 39 | func stopObserving() { 40 | NotificationCenter.default.removeObserver(self) 41 | } 42 | 43 | deinit { 44 | stopObserving() 45 | } 46 | 47 | @objc private func willSave(notification: Notification) { 48 | guard let context = notification.object as? NSManagedObjectContext else { return } 49 | 50 | // Ignore saves that are generated by FetchAndSaveController 51 | if context.name == CloudCore.config.contextName { return } 52 | 53 | // Upload only for changes in root context that will be saved to persistentStore 54 | if context.parent != nil { return } 55 | 56 | converter.setUnconfirmedOperations(inserted: context.insertedObjects, 57 | updated: context.updatedObjects, 58 | deleted: context.deletedObjects) 59 | } 60 | 61 | @objc private func didSave(notification: Notification) { 62 | guard let context = notification.object as? NSManagedObjectContext else { return } 63 | if context.name == CloudCore.config.contextName { return } 64 | if context.parent != nil { return } 65 | 66 | if converter.notConfirmedConvertOperations.isEmpty && converter.recordIDsToDelete.isEmpty { return } 67 | 68 | DispatchQueue.global(qos: .utility).async { [weak self] in 69 | guard let listener = self else { return } 70 | CloudCore.delegate?.willSyncToCloud() 71 | 72 | let backgroundContext = listener.container.newBackgroundContext() 73 | backgroundContext.name = listener.cloudContextName 74 | 75 | let records = listener.converter.confirmConvertOperationsAndWait(in: backgroundContext) 76 | listener.cloudSaveOperationQueue.errorBlock = { listener.handle(error: $0, parentContext: backgroundContext) } 77 | listener.cloudSaveOperationQueue.addOperations(recordsToSave: records.recordsToSave, recordIDsToDelete: records.recordIDsToDelete) 78 | listener.cloudSaveOperationQueue.waitUntilAllOperationsAreFinished() 79 | 80 | do { 81 | if backgroundContext.hasChanges { 82 | try backgroundContext.save() 83 | } 84 | } catch { 85 | listener.delegate?.error(error: error, module: .some(.saveToCloud)) 86 | } 87 | 88 | CloudCore.delegate?.didSyncToCloud() 89 | } 90 | } 91 | 92 | private func handle(error: Error, parentContext: NSManagedObjectContext) { 93 | guard let cloudError = error as? CKError else { 94 | delegate?.error(error: error, module: .some(.saveToCloud)) 95 | return 96 | } 97 | 98 | switch cloudError.code { 99 | // Zone was accidentally deleted (NOT PURGED), we need to reupload all data accroding Apple Guidelines 100 | case .zoneNotFound: 101 | cloudSaveOperationQueue.cancelAllOperations() 102 | 103 | // Create CloudCore Zone 104 | let createZoneOperation = CreateCloudCoreZoneOperation() 105 | createZoneOperation.errorBlock = { 106 | self.delegate?.error(error: $0, module: .some(.saveToCloud)) 107 | self.cloudSaveOperationQueue.cancelAllOperations() 108 | } 109 | 110 | // Subscribe operation 111 | #if !os(watchOS) 112 | let subscribeOperation = SubscribeOperation() 113 | subscribeOperation.errorBlock = { self.delegate?.error(error: $0, module: .some(.saveToCloud)) } 114 | subscribeOperation.addDependency(createZoneOperation) 115 | cloudSaveOperationQueue.addOperation(subscribeOperation) 116 | #endif 117 | 118 | // Upload all local data 119 | let uploadOperation = UploadAllLocalDataOperation(parentContext: parentContext, managedObjectModel: container.managedObjectModel) 120 | uploadOperation.errorBlock = { self.delegate?.error(error: $0, module: .some(.saveToCloud)) } 121 | 122 | cloudSaveOperationQueue.addOperations([createZoneOperation, uploadOperation], waitUntilFinished: true) 123 | case .operationCancelled: return 124 | default: delegate?.error(error: cloudError, module: .some(.saveToCloud)) 125 | } 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /Source/Classes/Save/Model/RecordIDWithDatabase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordIDWithDatabase.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 13/03/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | class RecordIDWithDatabase { 12 | let recordID: CKRecordID 13 | let database: CKDatabase 14 | 15 | init(_ recordID: CKRecordID, _ database: CKDatabase) { 16 | self.recordID = recordID 17 | self.database = database 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Source/Classes/Save/Model/RecordWithDatabase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordWithDatabase.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 13/03/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | class RecordWithDatabase { 12 | let record: CKRecord 13 | let database: CKDatabase 14 | 15 | init(_ record: CKRecord, _ database: CKDatabase) { 16 | self.record = record 17 | self.database = database 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Source/Classes/Save/ObjectToRecord/CoreDataAttribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataAttribute.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | class CoreDataAttribute { 13 | typealias Class = CoreDataAttribute 14 | 15 | let name: String 16 | let value: Any? 17 | let description: NSAttributeDescription 18 | 19 | /// Initialize Core Data Attribute with properties and value 20 | /// - Returns: `nil` if it is not an attribute (possible it is relationship?) 21 | init?(value: Any?, attributeName: String, entity: NSEntityDescription) { 22 | guard let description = CoreDataAttribute.attributeDescription(for: attributeName, in: entity) else { 23 | // it is not an attribute 24 | return nil 25 | } 26 | 27 | self.description = description 28 | 29 | if value is NSNull { 30 | self.value = nil 31 | } else { 32 | self.value = value 33 | } 34 | 35 | self.name = attributeName 36 | } 37 | 38 | private static func attributeDescription(for lookupName: String, in entity: NSEntityDescription) -> NSAttributeDescription? { 39 | for (name, description) in entity.attributesByName { 40 | if lookupName == name { return description } 41 | } 42 | 43 | return nil 44 | } 45 | 46 | /// Return value in CloudKit-friendly format that is usable in CKRecord 47 | /// - note: Possible long operation (if attribute has binary data asset maybe created) 48 | func makeRecordValue() throws -> Any? { 49 | switch self.description.attributeType { 50 | case .binaryDataAttributeType: 51 | guard let binaryData = self.value as? Data else { 52 | return nil 53 | } 54 | 55 | if binaryData.count > 1024*1024 || description.allowsExternalBinaryDataStorage { 56 | return try Class.createAsset(for: binaryData) 57 | } else { 58 | return binaryData 59 | } 60 | default: return self.value 61 | } 62 | } 63 | 64 | static func createAsset(for data: Data) throws -> CKAsset { 65 | let fileName = UUID().uuidString.lowercased() + ".bin" 66 | let fullURL = URL(fileURLWithPath: fileName, relativeTo: FileManager.default.temporaryDirectory) 67 | 68 | try data.write(to: fullURL) 69 | return CKAsset(fileURL: fullURL) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Source/Classes/Save/ObjectToRecord/CoreDataRelationship.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataRelationship.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 04.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | class CoreDataRelationship { 13 | typealias Class = CoreDataRelationship 14 | 15 | let value: Any 16 | let description: NSRelationshipDescription 17 | 18 | /// Initialize Core Data Attribute with properties and value 19 | /// - Returns: `nil` if it is not an attribute (possible it is relationship?) 20 | init?(value: Any, relationshipName: String, entity: NSEntityDescription) { 21 | guard let description = Class.relationshipDescription(for: relationshipName, in: entity) else { 22 | // it is not a relationship 23 | return nil 24 | } 25 | 26 | self.description = description 27 | self.value = value 28 | } 29 | 30 | private static func relationshipDescription(for lookupName: String, in entity: NSEntityDescription) -> NSRelationshipDescription? { 31 | for (name, description) in entity.relationshipsByName { 32 | if lookupName == name { return description } 33 | } 34 | 35 | return nil 36 | } 37 | 38 | /// Make reference(s) for relationship 39 | /// 40 | /// - Returns: `CKReference` or `[CKReference]` 41 | func makeRecordValue() throws -> Any? { 42 | if self.description.isToMany { 43 | if value is NSOrderedSet { 44 | throw CloudCoreError.orderedSetRelationshipIsNotSupported(description) 45 | } 46 | 47 | guard let objectsSet = value as? NSSet else { return nil } 48 | 49 | var referenceList = [CKReference]() 50 | for (_, managedObject) in objectsSet.enumerated() { 51 | guard let managedObject = managedObject as? NSManagedObject, 52 | let reference = try makeReference(from: managedObject) else { continue } 53 | 54 | referenceList.append(reference) 55 | } 56 | 57 | if referenceList.isEmpty { return nil } 58 | 59 | return referenceList 60 | } else { 61 | guard let object = value as? NSManagedObject else { return nil } 62 | 63 | return try makeReference(from: object) 64 | } 65 | } 66 | 67 | private func makeReference(from managedObject: NSManagedObject) throws -> CKReference? { 68 | let action: CKReferenceAction 69 | if case .some(NSDeleteRule.cascadeDeleteRule) = description.inverseRelationship?.deleteRule { 70 | action = .deleteSelf 71 | } else { 72 | action = .none 73 | } 74 | 75 | guard let record = try managedObject.restoreRecordWithSystemFields() else { 76 | // That is possible if method is called before all managed object were filled with recordData 77 | // That may cause possible reference corruption (Core Data -> iCloud), but it is not critical 78 | assertionFailure("Managed Object doesn't have stored record information, should be reported as a framework bug") 79 | return nil 80 | } 81 | 82 | return CKReference(record: record, action: action) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /Source/Classes/Save/ObjectToRecord/ObjectToRecordConverter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectToRecordConverter.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 09.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | class ObjectToRecordConverter { 13 | enum ManagedObjectChangeType { 14 | case inserted, updated 15 | } 16 | 17 | var errorBlock: ErrorBlock? 18 | 19 | private(set) var notConfirmedConvertOperations = [ObjectToRecordOperation]() 20 | private let operationQueue = OperationQueue() 21 | 22 | private var convertedRecords = [RecordWithDatabase]() 23 | private(set) var recordIDsToDelete = [RecordIDWithDatabase]() 24 | 25 | func setUnconfirmedOperations(inserted: Set, updated: Set, deleted: Set) { 26 | self.notConfirmedConvertOperations = self.convertOperations(from: inserted, changeType: .inserted) 27 | self.notConfirmedConvertOperations += self.convertOperations(from: updated, changeType: .updated) 28 | 29 | self.recordIDsToDelete = convert(deleted: deleted) 30 | } 31 | 32 | private func convertOperations(from objectSet: Set, changeType: ManagedObjectChangeType) -> [ObjectToRecordOperation] { 33 | var operations = [ObjectToRecordOperation]() 34 | 35 | for object in objectSet { 36 | // Ignore entities that doesn't have required service attributes 37 | guard let serviceAttributeNames = object.entity.serviceAttributeNames else { continue } 38 | 39 | do { 40 | let recordWithSystemFields: CKRecord 41 | 42 | if let restoredRecord = try object.restoreRecordWithSystemFields() { 43 | switch changeType { 44 | case .inserted: 45 | // 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 46 | let recordID = restoredRecord.recordID 47 | recordWithSystemFields = CKRecord(recordType: restoredRecord.recordType, recordID: recordID) 48 | case .updated: 49 | recordWithSystemFields = restoredRecord 50 | } 51 | } else { 52 | recordWithSystemFields = try object.setRecordInformation() 53 | } 54 | 55 | var changedAttributes: [String]? 56 | 57 | // Save changes keys only for updated object, for inserted objects full sync will be used 58 | if case .updated = changeType { changedAttributes = Array(object.changedValues().keys) } 59 | 60 | let convertOperation = ObjectToRecordOperation(record: recordWithSystemFields, 61 | changedAttributes: changedAttributes, 62 | serviceAttributeNames: serviceAttributeNames) 63 | 64 | convertOperation.errorCompletionBlock = { [weak self] error in 65 | self?.errorBlock?(error) 66 | } 67 | 68 | convertOperation.conversionCompletionBlock = { [weak self] record in 69 | guard let me = self else { return } 70 | 71 | let cloudDatabase = me.database(for: record.recordID, serviceAttributes: serviceAttributeNames) 72 | let recordWithDB = RecordWithDatabase(record, cloudDatabase) 73 | me.convertedRecords.append(recordWithDB) 74 | } 75 | 76 | operations.append(convertOperation) 77 | } catch { 78 | errorBlock?(error) 79 | } 80 | } 81 | 82 | return operations 83 | } 84 | 85 | private func convert(deleted objectSet: Set) -> [RecordIDWithDatabase] { 86 | var recordIDs = [RecordIDWithDatabase]() 87 | 88 | for object in objectSet { 89 | if let triedRestoredRecord = try? object.restoreRecordWithSystemFields(), 90 | let restoredRecord = triedRestoredRecord, 91 | let serviceAttributeNames = object.entity.serviceAttributeNames { 92 | let database = self.database(for: restoredRecord.recordID, serviceAttributes: serviceAttributeNames) 93 | let recordIDWithDB = RecordIDWithDatabase(restoredRecord.recordID, database) 94 | recordIDs.append(recordIDWithDB) 95 | } 96 | } 97 | 98 | return recordIDs 99 | } 100 | 101 | /// Add all uncofirmed operations to operation queue 102 | /// - attention: Don't call this method from same context's `perfom`, that will cause deadlock 103 | func confirmConvertOperationsAndWait(in context: NSManagedObjectContext) -> (recordsToSave: [RecordWithDatabase], recordIDsToDelete: [RecordIDWithDatabase]) { 104 | for operation in notConfirmedConvertOperations { 105 | operation.parentContext = context 106 | operationQueue.addOperation(operation) 107 | } 108 | 109 | notConfirmedConvertOperations = [ObjectToRecordOperation]() 110 | operationQueue.waitUntilAllOperationsAreFinished() 111 | 112 | let recordsToSave = self.convertedRecords 113 | let recordIDsToDelete = self.recordIDsToDelete 114 | 115 | self.convertedRecords = [RecordWithDatabase]() 116 | self.recordIDsToDelete = [RecordIDWithDatabase]() 117 | 118 | return (recordsToSave, recordIDsToDelete) 119 | } 120 | 121 | /// Get appropriate database for modify operations 122 | private func database(for recordID: CKRecordID, serviceAttributes: ServiceAttributeNames) -> CKDatabase { 123 | let container = CloudCore.config.container 124 | 125 | if serviceAttributes.isPublic { return container.publicCloudDatabase } 126 | 127 | let ownerName = recordID.zoneID.ownerName 128 | 129 | if ownerName == CKCurrentUserDefaultName { 130 | return container.privateCloudDatabase 131 | } else { 132 | return container.sharedCloudDatabase 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Source/Classes/Save/ObjectToRecord/ObjectToRecordOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectToRecordOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 09.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | import CoreData 11 | 12 | class ObjectToRecordOperation: Operation { 13 | /// Need to set before starting operation, child context from it will be created 14 | var parentContext: NSManagedObjectContext? 15 | 16 | // Set on init 17 | let record: CKRecord 18 | private let changedAttributes: [String]? 19 | private let serviceAttributeNames: ServiceAttributeNames 20 | // 21 | 22 | var errorCompletionBlock: ((Error) -> Void)? 23 | var conversionCompletionBlock: ((CKRecord) -> Void)? 24 | 25 | init(record: CKRecord, changedAttributes: [String]?, serviceAttributeNames: ServiceAttributeNames) { 26 | self.record = record 27 | self.changedAttributes = changedAttributes 28 | self.serviceAttributeNames = serviceAttributeNames 29 | 30 | super.init() 31 | self.name = "ObjectToRecordOperation" 32 | } 33 | 34 | override func main() { 35 | if self.isCancelled { return } 36 | guard let parentContext = parentContext else { 37 | let error = CloudCoreError.coreData("CloudCore framework error") 38 | errorCompletionBlock?(error) 39 | return 40 | } 41 | 42 | let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 43 | childContext.parent = parentContext 44 | 45 | do { 46 | try self.fillRecordWithData(using: childContext) 47 | try childContext.save() 48 | self.conversionCompletionBlock?(self.record) 49 | } catch { 50 | self.errorCompletionBlock?(error) 51 | } 52 | } 53 | 54 | private func fillRecordWithData(using context: NSManagedObjectContext) throws { 55 | guard let managedObject = try fetchObject(for: record, using: context) else { 56 | throw CloudCoreError.coreData("Unable to find managed object for record: \(record)") 57 | } 58 | 59 | let changedValues = managedObject.committedValues(forKeys: changedAttributes) 60 | 61 | for (attributeName, value) in changedValues { 62 | if attributeName == serviceAttributeNames.recordData || attributeName == serviceAttributeNames.recordID { continue } 63 | 64 | if let attribute = CoreDataAttribute(value: value, attributeName: attributeName, entity: managedObject.entity) { 65 | let recordValue = try attribute.makeRecordValue() 66 | record.setValue(recordValue, forKey: attributeName) 67 | } else if let relationship = CoreDataRelationship(value: value, relationshipName: attributeName, entity: managedObject.entity) { 68 | let references = try relationship.makeRecordValue() 69 | record.setValue(references, forKey: attributeName) 70 | } 71 | } 72 | } 73 | 74 | private func fetchObject(for record: CKRecord, using context: NSManagedObjectContext) throws -> NSManagedObject? { 75 | let entityName = record.recordType 76 | 77 | let fetchRequest = NSFetchRequest(entityName: entityName) 78 | fetchRequest.predicate = NSPredicate(format: serviceAttributeNames.recordID + " == %@", record.recordID.encodedString) 79 | 80 | return try context.fetch(fetchRequest).first as? NSManagedObject 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Source/Classes/Setup Operation/CreateCloudCoreZoneOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateCloudCoreZoneOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 12/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | class CreateCloudCoreZoneOperation: AsynchronousOperation { 13 | 14 | var errorBlock: ErrorBlock? 15 | private var createZoneOperation: CKModifyRecordZonesOperation? 16 | 17 | override func main() { 18 | super.main() 19 | 20 | let cloudCoreZone = CKRecordZone(zoneName: CloudCore.config.zoneID.zoneName) 21 | let recordZoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: [cloudCoreZone], recordZoneIDsToDelete: nil) 22 | recordZoneOperation.modifyRecordZonesCompletionBlock = { 23 | if let error = $2 { 24 | self.errorBlock?(error) 25 | } 26 | 27 | self.state = .finished 28 | } 29 | 30 | CloudCore.config.container.privateCloudDatabase.add(recordZoneOperation) 31 | self.createZoneOperation = recordZoneOperation 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Source/Classes/Setup Operation/SetupOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetupOperation.swift 3 | // CloudCore-iOS 4 | // 5 | // Created by Vasily Ulianov on 13/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | /** 13 | Performs several setup operations: 14 | 15 | 1. Create CloudCore Zone. 16 | 2. Subscribe to that zone. 17 | 3. Upload all local data to cloud. 18 | */ 19 | class SetupOperation: Operation { 20 | 21 | var errorBlock: ErrorBlock? 22 | let container: NSPersistentContainer 23 | let parentContext: NSManagedObjectContext? 24 | 25 | /// - Parameters: 26 | /// - container: persistent container to get managedObject model from 27 | /// - parentContext: context where changed data will be save (recordID's). If it is `nil`, new context will be created from `container` and saved 28 | init(container: NSPersistentContainer, parentContext: NSManagedObjectContext?) { 29 | self.container = container 30 | self.parentContext = parentContext 31 | } 32 | 33 | private let queue = OperationQueue() 34 | 35 | override func main() { 36 | super.main() 37 | 38 | let childContext: NSManagedObjectContext 39 | 40 | if let parentContext = self.parentContext { 41 | childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 42 | childContext.parent = parentContext 43 | } else { 44 | childContext = container.newBackgroundContext() 45 | } 46 | 47 | // Create CloudCore Zone 48 | let createZoneOperation = CreateCloudCoreZoneOperation() 49 | createZoneOperation.errorBlock = { 50 | self.errorBlock?($0) 51 | self.queue.cancelAllOperations() 52 | } 53 | 54 | // Subscribe operation 55 | #if !os(watchOS) 56 | let subscribeOperation = SubscribeOperation() 57 | subscribeOperation.errorBlock = errorBlock 58 | subscribeOperation.addDependency(createZoneOperation) 59 | queue.addOperation(subscribeOperation) 60 | #endif 61 | 62 | // Upload all local data 63 | let uploadOperation = UploadAllLocalDataOperation(parentContext: childContext, managedObjectModel: container.managedObjectModel) 64 | uploadOperation.errorBlock = errorBlock 65 | 66 | #if !os(watchOS) 67 | uploadOperation.addDependency(subscribeOperation) 68 | #endif 69 | 70 | queue.addOperations([createZoneOperation, uploadOperation], waitUntilFinished: true) 71 | 72 | if self.parentContext == nil { 73 | do { 74 | // It's safe to save because we instatinated that context in current thread 75 | try childContext.save() 76 | } catch { 77 | errorBlock?(error) 78 | } 79 | } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /Source/Classes/Setup Operation/SubscribeOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubscribeOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 12/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | #if !os(watchOS) 13 | @available(watchOS, unavailable) 14 | class SubscribeOperation: AsynchronousOperation { 15 | 16 | var errorBlock: ErrorBlock? 17 | 18 | private let queue = OperationQueue() 19 | 20 | override func main() { 21 | super.main() 22 | 23 | let container = CloudCore.config.container 24 | 25 | // Subscribe operation 26 | let subcribeToPrivate = self.makeRecordZoneSubscriptionOperation(for: container.privateCloudDatabase, id: CloudCore.config.subscriptionIDForPrivateDB) 27 | 28 | // Fetch subscriptions and cancel subscription operation if subscription is already exists 29 | let fetchPrivateSubscriptions = makeFetchSubscriptionOperation(for: container.privateCloudDatabase, 30 | searchForSubscriptionID: CloudCore.config.subscriptionIDForPrivateDB, 31 | operationToCancelIfSubcriptionExists: subcribeToPrivate) 32 | 33 | subcribeToPrivate.addDependency(fetchPrivateSubscriptions) 34 | 35 | // Finish operation 36 | let finishOperation = BlockOperation { 37 | self.state = .finished 38 | } 39 | finishOperation.addDependency(subcribeToPrivate) 40 | finishOperation.addDependency(fetchPrivateSubscriptions) 41 | 42 | queue.addOperations([subcribeToPrivate, fetchPrivateSubscriptions, finishOperation], waitUntilFinished: false) 43 | } 44 | 45 | private func makeRecordZoneSubscriptionOperation(for database: CKDatabase, id: String) -> CKModifySubscriptionsOperation { 46 | let notificationInfo = CKNotificationInfo() 47 | notificationInfo.shouldSendContentAvailable = true 48 | 49 | let subscription = CKRecordZoneSubscription(zoneID: CloudCore.config.zoneID, subscriptionID: id) 50 | subscription.notificationInfo = notificationInfo 51 | 52 | let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) 53 | operation.modifySubscriptionsCompletionBlock = { 54 | if let error = $2 { 55 | // Cancellation is not an error 56 | if case CKError.operationCancelled = error { return } 57 | 58 | self.errorBlock?(error) 59 | } 60 | } 61 | 62 | operation.database = database 63 | 64 | return operation 65 | } 66 | 67 | private func makeFetchSubscriptionOperation(for database: CKDatabase, searchForSubscriptionID subscriptionID: String, operationToCancelIfSubcriptionExists operationToCancel: CKModifySubscriptionsOperation) -> CKFetchSubscriptionsOperation { 68 | let fetchSubscriptions = CKFetchSubscriptionsOperation(subscriptionIDs: [subscriptionID]) 69 | fetchSubscriptions.database = database 70 | fetchSubscriptions.fetchSubscriptionCompletionBlock = { subscriptions, error in 71 | // If no errors = subscription is found and we don't need to subscribe again 72 | if error == nil { 73 | operationToCancel.cancel() 74 | } 75 | } 76 | 77 | return fetchSubscriptions 78 | } 79 | 80 | } 81 | #endif 82 | -------------------------------------------------------------------------------- /Source/Classes/Setup Operation/UploadAllLocalDataOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadAllLocalDataOperation.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 12/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | class UploadAllLocalDataOperation: Operation { 13 | 14 | let managedObjectModel: NSManagedObjectModel 15 | let parentContext: NSManagedObjectContext 16 | 17 | var errorBlock: ErrorBlock? { 18 | didSet { 19 | converter.errorBlock = errorBlock 20 | cloudSaveOperationQueue.errorBlock = errorBlock 21 | } 22 | } 23 | 24 | private let converter = ObjectToRecordConverter() 25 | private let cloudSaveOperationQueue = CloudSaveOperationQueue() 26 | 27 | init(parentContext: NSManagedObjectContext, managedObjectModel: NSManagedObjectModel) { 28 | self.parentContext = parentContext 29 | self.managedObjectModel = managedObjectModel 30 | } 31 | 32 | override func main() { 33 | super.main() 34 | 35 | CloudCore.delegate?.willSyncToCloud() 36 | defer { 37 | CloudCore.delegate?.didSyncToCloud() 38 | } 39 | 40 | let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 41 | childContext.parent = parentContext 42 | 43 | var allManagedObjects = Set() 44 | for entity in managedObjectModel.cloudCoreEnabledEntities { 45 | guard let entityName = entity.name else { continue } 46 | let fetchRequest = NSFetchRequest(entityName: entityName) 47 | 48 | do { 49 | guard let fetchedObjects = try childContext.fetch(fetchRequest) as? [NSManagedObject] else { 50 | continue 51 | } 52 | 53 | allManagedObjects.formUnion(fetchedObjects) 54 | } catch { 55 | errorBlock?(error) 56 | } 57 | } 58 | 59 | converter.setUnconfirmedOperations(inserted: allManagedObjects, updated: Set(), deleted: Set()) 60 | let recordsToSave = converter.confirmConvertOperationsAndWait(in: childContext).recordsToSave 61 | cloudSaveOperationQueue.addOperations(recordsToSave: recordsToSave, recordIDsToDelete: [RecordIDWithDatabase]()) 62 | cloudSaveOperationQueue.waitUntilAllOperationsAreFinished() 63 | 64 | do { 65 | try childContext.save() 66 | } catch { 67 | errorBlock?(error) 68 | } 69 | } 70 | 71 | override func cancel() { 72 | cloudSaveOperationQueue.cancelAllOperations() 73 | 74 | super.cancel() 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Source/Enum/CloudCoreError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudCoreError.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | /// A enumeration representing an error value that can be thrown by framework 13 | public enum CloudCoreError: Error, CustomStringConvertible { 14 | /// Entity doesn't have some required attributes 15 | case missingServiceAttributes(entityName: String?) 16 | 17 | /// Some CloudKit error 18 | case cloudKit(String) 19 | 20 | /// Some CoreData error 21 | case coreData(String) 22 | 23 | /// Custom error, description is placed inside associated value 24 | case custom(String) 25 | 26 | 27 | /// CloudCore doesn't support relationships with `NSOrderedSet` type 28 | case orderedSetRelationshipIsNotSupported(NSRelationshipDescription) 29 | 30 | /// A textual representation of error 31 | public var localizedDescription: String { 32 | switch self { 33 | case .missingServiceAttributes(let entity): 34 | let entityName = entity ?? "UNKNOWN_ENTITY" 35 | return entityName + " doesn't contain all required services attributes" 36 | case .cloudKit(let text): return "iCloud error: \(text)" 37 | case .coreData(let text): return "Core Data error: \(text)" 38 | case .custom(let error): return error 39 | case .orderedSetRelationshipIsNotSupported(let relationship): return "Relationships with NSOrderedSet type are not supported. Error occured in: \(relationship)" 40 | } 41 | } 42 | 43 | /// A textual representation of error 44 | public var description: String { return self.localizedDescription } 45 | } 46 | 47 | public typealias ErrorBlock = (Error) -> Void 48 | -------------------------------------------------------------------------------- /Source/Enum/FetchResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchResult.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 08.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | /// Enumeration with results of `FetchAndSaveOperation`. 13 | public enum FetchResult: UInt { 14 | /// Fetching has successfully completed without any errors 15 | case newData = 0 16 | 17 | /// No fetching was done, maybe fired with `FetchAndSaveOperation` was called with incorrect UserInfo without CloudCore's data 18 | case noData = 1 19 | 20 | /// There were some errors during operation 21 | case failed = 2 22 | } 23 | 24 | #if os(iOS) 25 | import UIKit 26 | 27 | public extension FetchResult { 28 | 29 | /// Convert `self` to `UIBackgroundFetchResult` 30 | /// 31 | /// Very usefull at `application(_:didReceiveRemoteNotification:fetchCompletionHandler)` as `completionHandler` 32 | public var uiBackgroundFetchResult: UIBackgroundFetchResult { 33 | return UIBackgroundFetchResult(rawValue: self.rawValue)! 34 | } 35 | 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /Source/Enum/Module.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Module.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 14/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Enumeration with module name that issued an error in `CloudCoreErrorDelegate` 12 | public enum Module { 13 | 14 | /// Save to CloudKit module 15 | case saveToCloud 16 | 17 | /// Fetch from CloudKit module 18 | case fetchFromCloud 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Source/Extensions/CKRecordID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudRecordID.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | extension CKRecordID { 12 | private static let separator = "|" 13 | 14 | /// Init from encoded string 15 | /// 16 | /// - Parameter encodedString: format: `recordName|ownerName` 17 | convenience init?(encodedString: String) { 18 | let separated = encodedString.components(separatedBy: CKRecordID.separator) 19 | 20 | if separated.count == 2 { 21 | let zoneID = CKRecordZoneID(zoneName: CloudCore.config.zoneID.zoneName, ownerName: separated[1]) 22 | self.init(recordName: separated[0], zoneID: zoneID) 23 | } else { 24 | return nil 25 | } 26 | } 27 | 28 | /// Encoded string in format: `recordName|ownerName` 29 | var encodedString: String { 30 | return recordName + CKRecordID.separator + zoneID.ownerName 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Source/Extensions/NSEntityDescription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSEntityDescription.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 07.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | extension NSEntityDescription { 12 | var serviceAttributeNames: ServiceAttributeNames? { 13 | guard let entityName = self.name else { return nil } 14 | 15 | let attributeNamesFromUserInfo = self.parseAttributeNamesFromUserInfo() 16 | 17 | // Get required attributes 18 | 19 | // Record Data 20 | let recordDataName: String 21 | if let recordDataUserInfoName = attributeNamesFromUserInfo.recordData { 22 | recordDataName = recordDataUserInfoName 23 | } else { 24 | // Last chance: try to find default attribute name in entity 25 | if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameRecordData) { 26 | recordDataName = CloudCore.config.defaultAttributeNameRecordData 27 | } else { 28 | return nil 29 | } 30 | } 31 | 32 | // Record ID 33 | let recordIDName: String 34 | if let recordIDUserInfoName = attributeNamesFromUserInfo.recordID { 35 | recordIDName = recordIDUserInfoName 36 | } else { 37 | // Last chance: try to find default attribute name in entity 38 | if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameRecordID) { 39 | recordIDName = CloudCore.config.defaultAttributeNameRecordID 40 | } else { 41 | return nil 42 | } 43 | } 44 | 45 | return ServiceAttributeNames(entityName: entityName, recordData: recordDataName, recordID: recordIDName, isPublic: attributeNamesFromUserInfo.isPublic) 46 | } 47 | 48 | /// Parse data from User Info dictionary 49 | private func parseAttributeNamesFromUserInfo() -> (isPublic: Bool, recordData: String?, recordID: String?) { 50 | var recordDataName: String? 51 | var recordIDName: String? 52 | var isPublic = false 53 | 54 | // In attribute 55 | for (attributeName, attributeDescription) in self.attributesByName { 56 | guard let userInfo = attributeDescription.userInfo else { continue } 57 | 58 | // In userInfo dictionary 59 | for (key, value) in userInfo { 60 | guard let key = key as? String, 61 | let value = value as? String else { continue } 62 | 63 | if key == ServiceAttributeNames.keyType { 64 | switch value { 65 | case ServiceAttributeNames.valueRecordID: recordIDName = attributeName 66 | case ServiceAttributeNames.valueRecordData: recordDataName = attributeName 67 | default: continue 68 | } 69 | } else if key == ServiceAttributeNames.keyIsPublic { 70 | if value == "true" { isPublic = true } 71 | } 72 | } 73 | } 74 | 75 | return (isPublic, recordDataName, recordIDName) 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Source/Extensions/NSManagedObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObject.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import CloudKit 11 | 12 | extension NSManagedObject { 13 | /// Restore record with system fields if that data is saved in recordData attribute (name of attribute is set through user info) 14 | /// 15 | /// - Returns: unacrhived `CKRecord` containing restored system fields (like RecordID, tokens, creationg date etc) 16 | /// - Throws: `CloudCoreError.missingServiceAttributes` if names of CloudCore attributes are not specified in User Info 17 | func restoreRecordWithSystemFields() throws -> CKRecord? { 18 | guard let serviceAttributeNames = self.entity.serviceAttributeNames else { 19 | throw CloudCoreError.missingServiceAttributes(entityName: self.entity.name) 20 | } 21 | guard let encodedRecordData = self.value(forKey: serviceAttributeNames.recordData) as? Data else { return nil } 22 | 23 | return CKRecord(archivedData: encodedRecordData) 24 | } 25 | 26 | 27 | /// Create new CKRecord, write one's encdodedSystemFields and record id to `self` 28 | /// - Postcondition: `self` is modified (recordData and recordID is written) 29 | /// - Throws: may throw exception if unable to find attributes marked by User Info as service attributes 30 | /// - Returns: new `CKRecord` 31 | @discardableResult func setRecordInformation() throws -> CKRecord { 32 | guard let entityName = self.entity.name else { 33 | throw CloudCoreError.coreData("No entity name for \(self.entity)") 34 | } 35 | guard let serviceAttributeNames = self.entity.serviceAttributeNames else { 36 | throw CloudCoreError.missingServiceAttributes(entityName: self.entity.name) 37 | } 38 | 39 | let record = CKRecord(recordType: entityName, zoneID: CloudCore.config.zoneID) 40 | self.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.recordData) 41 | self.setValue(record.recordID.encodedString, forKey: serviceAttributeNames.recordID) 42 | 43 | return record 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Source/Extensions/NSManagedObjectModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObjectModel.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 12/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | extension NSManagedObjectModel { 12 | 13 | var cloudCoreEnabledEntities: [NSEntityDescription] { 14 | var cloudCoreEntities = [NSEntityDescription]() 15 | 16 | for entity in self.entities { 17 | if entity.serviceAttributeNames != nil { 18 | cloudCoreEntities.append(entity) 19 | } 20 | } 21 | 22 | return cloudCoreEntities 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Source/Model/CKRecord.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CKRecord.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | extension CKRecord { 12 | convenience init?(archivedData: Data) { 13 | let unarchiver = NSKeyedUnarchiver(forReadingWith: archivedData) 14 | unarchiver.requiresSecureCoding = true 15 | self.init(coder: unarchiver) 16 | } 17 | 18 | var encdodedSystemFields: Data { 19 | let archivedData = NSMutableData() 20 | let archiver = NSKeyedArchiver(forWritingWith: archivedData) 21 | archiver.requiresSecureCoding = true 22 | self.encodeSystemFields(with: archiver) 23 | archiver.finishEncoding() 24 | 25 | return archivedData as Data 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Source/Model/CloudCoreConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Scheme.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | 13 | /** 14 | Struct containing CloudCore configuration. 15 | 16 | Changes in configuration are optional and they are not required in most cases. 17 | 18 | ## Example 19 | 20 | ```swift 21 | var customConfig = CloudCoreConfig() 22 | customConfig.publicSubscriptionIDPrefix = "CustomApp" 23 | CloudCore.config = customConfig 24 | ``` 25 | */ 26 | public struct CloudCoreConfig { 27 | 28 | // MARK: CloudKit 29 | 30 | /// The CKContainer to store CoreData. Set this to a custom container to 31 | /// support sharing data between multiple apps in an App Group (e.g. iOS and macOS). 32 | /// 33 | /// Default value is `CKContainer.default()` 34 | // `lazy` is set to eliminate crashes during unit-tests 35 | public lazy var container = CKContainer.default() 36 | 37 | /// RecordZone inside private database to store CoreData. 38 | /// 39 | /// Default value is `CloudCore` 40 | public var zoneID = CKRecordZoneID(zoneName: "CloudCore", ownerName: CKCurrentUserDefaultName) 41 | let subscriptionIDForPrivateDB = "CloudCorePrivate" 42 | let subscriptionIDForSharedDB = "CloudCoreShared" 43 | 44 | /// subscriptionID's prefix for custom CKSubscription in public databases 45 | var publicSubscriptionIDPrefix = "CloudCore-" 46 | 47 | // MARK: Core Data 48 | let contextName = "CloudCoreFetchAndSave" 49 | 50 | /// Default entity's attribute name for *Record ID* if User Info is not specified. 51 | /// 52 | /// Default value is `recordID` 53 | public var defaultAttributeNameRecordID = "recordID" 54 | 55 | /// Default entity's attribute name for *Record Data* if User Info is not specified 56 | /// 57 | /// Default value is `recordData` 58 | public var defaultAttributeNameRecordData = "recordData" 59 | 60 | // MARK: User Defaults 61 | 62 | /// UserDefault's key to store `Tokens` object 63 | /// 64 | /// Default value is `CloudCoreTokens` 65 | public var userDefaultsKeyTokens = "CloudCoreTokens" 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Source/Model/CloudKitAttribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitAttribute.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 08.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | import CoreData 11 | 12 | enum CloudKitAttributeError: Error { 13 | case unableToFindTargetEntity 14 | } 15 | 16 | class CloudKitAttribute { 17 | let value: Any? 18 | let fieldName: String 19 | let entityName: String 20 | let serviceAttributes: ServiceAttributeNames 21 | let context: NSManagedObjectContext 22 | 23 | init(value: Any?, fieldName: String, entityName: String, serviceAttributes: ServiceAttributeNames, context: NSManagedObjectContext) { 24 | self.value = value 25 | self.fieldName = fieldName 26 | self.entityName = entityName 27 | self.serviceAttributes = serviceAttributes 28 | self.context = context 29 | } 30 | 31 | func makeCoreDataValue() throws -> Any? { 32 | switch value { 33 | case let reference as CKReference: return try findManagedObject(for: reference.recordID) 34 | case let references as [CKReference]: 35 | let managedObjects = NSMutableSet() 36 | for ref in references { 37 | guard let foundObject = try findManagedObject(for: ref.recordID) else { continue } 38 | managedObjects.add(foundObject) 39 | } 40 | 41 | if managedObjects.count == 0 { return nil } 42 | return managedObjects 43 | case let asset as CKAsset: return try Data(contentsOf: asset.fileURL) 44 | default: return value 45 | } 46 | } 47 | 48 | private func findManagedObject(for recordID: CKRecordID) throws -> NSManagedObject? { 49 | let targetEntityName = try findTargetEntityName() 50 | let fetchRequest = NSFetchRequest(entityName: targetEntityName) 51 | 52 | // FIXME: user serviceAttributes.recordID from target entity (not from me) 53 | 54 | fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordID + " == %@" , recordID.encodedString) 55 | fetchRequest.fetchLimit = 1 56 | fetchRequest.includesPropertyValues = false 57 | 58 | let foundObject = try context.fetch(fetchRequest).first as? NSManagedObject 59 | 60 | return foundObject 61 | } 62 | 63 | private var myRelationship: NSRelationshipDescription? { 64 | let myEntity = NSEntityDescription.entity(forEntityName: entityName, in: context) 65 | return myEntity?.relationshipsByName[fieldName] 66 | } 67 | 68 | private func findTargetEntityName() throws -> String { 69 | guard let myRelationship = self.myRelationship, 70 | let destinationEntityName = myRelationship.destinationEntity?.name else { 71 | throw CloudKitAttributeError.unableToFindTargetEntity 72 | } 73 | 74 | return destinationEntityName 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Source/Model/ServiceAttributeName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceAttributeName.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 11.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | struct ServiceAttributeNames { 12 | // User Info keys & values 13 | static let keyType = "CloudCoreType" 14 | static let keyIsPublic = "CloudCorePublicDatabase" 15 | 16 | static let valueRecordData = "recordData" 17 | static let valueRecordID = "recordID" 18 | 19 | let entityName: String 20 | let recordData: String 21 | let recordID: String 22 | let isPublic: Bool 23 | } 24 | -------------------------------------------------------------------------------- /Source/Model/Tokens.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tokens.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 07.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | 11 | /** 12 | CloudCore's class for storing global `CKToken` objects. Framework uses one to upload or download only changed data (smart-sync). 13 | 14 | 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. 15 | 16 | Framework stores tokens in 2 places: 17 | 18 | * singleton `Tokens` object in `CloudCore.tokens` 19 | * tokens per record inside *Record Data* attribute, it is managed automatically you don't need to take any actions about that token 20 | 21 | You need to save `Tokens` object before application terminates otherwise you will loose smart-sync ability. 22 | 23 | ### Example 24 | ```swift 25 | func applicationWillTerminate(_ application: UIApplication) { 26 | CloudCore.tokens.saveToUserDefaults() 27 | } 28 | ``` 29 | */ 30 | open class Tokens: NSObject, NSCoding { 31 | 32 | var tokensByRecordZoneID = [CKRecordZoneID: CKServerChangeToken]() 33 | 34 | private struct ArchiverKey { 35 | static let tokensByRecordZoneID = "tokensByRecordZoneID" 36 | } 37 | 38 | /// Create fresh object without any Tokens inside. Can be used to fetch full data. 39 | public override init() { 40 | super.init() 41 | } 42 | 43 | // MARK: User Defaults 44 | 45 | /// Load saved Tokens from UserDefaults. Key is used from `CloudCoreConfig.userDefaultsKeyTokens` 46 | /// 47 | /// - Returns: previously saved `Token` object, if tokens weren't saved before newly initialized `Tokens` object will be returned 48 | open static func loadFromUserDefaults() -> Tokens { 49 | guard let tokensData = UserDefaults.standard.data(forKey: CloudCore.config.userDefaultsKeyTokens), 50 | let tokens = NSKeyedUnarchiver.unarchiveObject(with: tokensData) as? Tokens else { 51 | return Tokens() 52 | } 53 | 54 | return tokens 55 | } 56 | 57 | /// Save tokens to UserDefaults and synchronize. Key is used from `CloudCoreConfig.userDefaultsKeyTokens` 58 | open func saveToUserDefaults() { 59 | let tokensData = NSKeyedArchiver.archivedData(withRootObject: self) 60 | UserDefaults.standard.set(tokensData, forKey: CloudCore.config.userDefaultsKeyTokens) 61 | UserDefaults.standard.synchronize() 62 | } 63 | 64 | // MARK: NSCoding 65 | 66 | /// Returns an object initialized from data in a given unarchiver. 67 | public required init?(coder aDecoder: NSCoder) { 68 | if let decodedTokens = aDecoder.decodeObject(forKey: ArchiverKey.tokensByRecordZoneID) as? [CKRecordZoneID: CKServerChangeToken] { 69 | self.tokensByRecordZoneID = decodedTokens 70 | } else { 71 | return nil 72 | } 73 | } 74 | 75 | /// Encodes the receiver using a given archiver. 76 | open func encode(with aCoder: NSCoder) { 77 | aCoder.encode(tokensByRecordZoneID, forKey: ArchiverKey.tokensByRecordZoneID) 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Source/Protocols/CloudCoreDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudCoreDelegate.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 14/12/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Delegate for framework that can be used for proccesses tracking and error handling. 12 | /// Maybe usefull to activate `UIApplication.networkActivityIndicatorVisible`. 13 | /// All methods are optional. 14 | public protocol CloudCoreDelegate: class { 15 | 16 | // MARK: Notifications 17 | 18 | /// Tells the delegate that fetching data from CloudKit is about to begin 19 | func willSyncFromCloud() 20 | 21 | /// Tells the delegate that data fetching from CloudKit and updating local objects processes are now completed 22 | func didSyncFromCloud() 23 | 24 | /// Tells the delegate that conversion operations (NSManagedObject to CKRecord) and data uploading to CloudKit is about to begin 25 | func willSyncToCloud() 26 | 27 | /// Tells the delegate that data has been uploaded to CloudKit 28 | func didSyncToCloud() 29 | 30 | // MARK: Error 31 | 32 | /// Tells the delegate that error has been occured, maybe called multiple times 33 | /// 34 | /// - Parameters: 35 | /// - error: in most cases contains `CloudCoreError` or `CKError` 36 | /// - module: framework's module that throwed an error 37 | func error(error: Error, module: Module?) 38 | 39 | } 40 | 41 | public extension CloudCoreDelegate { 42 | 43 | func willSyncFromCloud() { } 44 | func didSyncFromCloud() { } 45 | func willSyncToCloud() { } 46 | func didSyncToCloud() { } 47 | func error(error: Error, module: Module?) { } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /Source/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Classes/ErrorBlockProxyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorBlockProxyTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import CloudCore 12 | 13 | class ErrorBlockProxyTests: XCTestCase { 14 | func testProxy() { 15 | var isErrorReceived = false 16 | let errorBlock: ErrorBlock = { _ in 17 | isErrorReceived = true 18 | } 19 | 20 | let proxy = ErrorBlockProxy(destination: errorBlock) 21 | 22 | // Check null error 23 | proxy.send(error: nil) 24 | XCTAssertFalse(proxy.wasError) 25 | XCTAssertFalse(isErrorReceived) 26 | 27 | // Check that proxy in proxifing 28 | proxy.send(error: CloudCoreError.custom("test")) 29 | XCTAssertTrue(proxy.wasError) 30 | XCTAssertTrue(isErrorReceived) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Classes/Fetch/Operations/DeleteFromCoreDataOperationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteFromCoreDataOperationTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | import CloudKit 12 | 13 | @testable import CloudCore 14 | 15 | class DeleteFromCoreDataOperationTests: CoreDataTestCase { 16 | 17 | // - MARK: Tests 18 | 19 | func testOperation() { 20 | let remainingObject = TestEntity(context: context) 21 | do { 22 | try remainingObject.setRecordInformation() 23 | 24 | let objectToDelete = TestEntity(context: context) 25 | let record = try objectToDelete.setRecordInformation() 26 | 27 | try context.save() 28 | 29 | let operation = DeleteFromCoreDataOperation(parentContext: context, recordID: record.recordID) 30 | operation.start() 31 | 32 | XCTAssertTrue(objectToDelete.isDeleted) 33 | XCTAssertFalse(remainingObject.isDeleted) 34 | } catch { 35 | XCTFail(error) 36 | } 37 | } 38 | 39 | func testOperationPerfomance() { 40 | // Make dummy objects 41 | let records = self.insertPerfomanceTestObjects() 42 | 43 | measure { 44 | let backgroundContext = self.persistentContainer.newBackgroundContext() 45 | 46 | let queue = OperationQueue() 47 | 48 | for record in records { 49 | let operation = DeleteFromCoreDataOperation(parentContext: backgroundContext, recordID: record.recordID) 50 | queue.addOperation(operation) 51 | } 52 | 53 | queue.waitUntilAllOperationsAreFinished() 54 | } 55 | } 56 | 57 | // - MARK: Helper methods 58 | 59 | /// Prepare for perfomance test (make and insert test objects) 60 | /// 61 | /// - Returns: records for inserted test objects 62 | private func insertPerfomanceTestObjects() -> [CKRecord] { 63 | var recordsToDelete = [CKRecord]() 64 | 65 | for _ in 1...300 { 66 | let objectToDelete = TestEntity(context: context) 67 | do { 68 | let record = try objectToDelete.setRecordInformation() 69 | recordsToDelete.append(record) 70 | } catch { 71 | XCTFail(error) 72 | } 73 | } 74 | 75 | do { 76 | try context.save() 77 | } catch { 78 | XCTFail(error) 79 | } 80 | 81 | return recordsToDelete 82 | } 83 | 84 | 85 | } 86 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Classes/Fetch/Operations/RecordToCoreDataOperationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordToCoreDataOperationTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | import CloudKit 12 | 13 | @testable import CloudCore 14 | 15 | class RecordToCoreDataOperationTests: CoreDataTestCase { 16 | 17 | // - MARK: Tests 18 | 19 | func testOperation() { 20 | let finishExpectation = expectation(description: "conversionFinished") 21 | let queue = OperationQueue() 22 | let (convertOperation, record) = makeConvertOperation(in: self.context) 23 | 24 | let checkOperation = BlockOperation { 25 | finishExpectation.fulfill() 26 | } 27 | checkOperation.addDependency(convertOperation) 28 | 29 | queue.addOperations([convertOperation, checkOperation], waitUntilFinished: false) 30 | 31 | wait(for: [finishExpectation], timeout: 2) 32 | 33 | self.fetchAndCheck(record: record, in: self.context) 34 | } 35 | 36 | func testOperationsPerformance() { 37 | measure { 38 | let backgroundContext = self.persistentContainer.newBackgroundContext() 39 | let queue = OperationQueue() 40 | 41 | for _ in 1...300 { 42 | let operation = self.makeConvertOperation(in: backgroundContext).operation 43 | queue.addOperation(operation) 44 | } 45 | 46 | queue.waitUntilAllOperationsAreFinished() 47 | } 48 | } 49 | 50 | // MARK: - Helper methods 51 | 52 | /// - Returns: conversion operation and source test `CKRecord` from what that operation was made 53 | func makeConvertOperation(in context: NSManagedObjectContext) -> (operation: RecordToCoreDataOperation, testRecord: CKRecord) { 54 | let record = CorrectObject().makeRecord() 55 | 56 | let convertOperation = RecordToCoreDataOperation(parentContext: context, record: record) 57 | convertOperation.errorBlock = { XCTFail("\($0)") } 58 | 59 | return (convertOperation, record) 60 | } 61 | 62 | /// Find NSManagedObject for specified record and assert if values in that object is equal to record's values 63 | private func fetchAndCheck(record: CKRecord, in context: NSManagedObjectContext) { 64 | context.performAndWait { 65 | // Check operation results 66 | let fetchRequest: NSFetchRequest = TestEntity.fetchRequest() 67 | fetchRequest.predicate = NSPredicate(format: "recordID = %@", record.recordID.encodedString) 68 | do { 69 | guard let managedObject = try context.fetch(fetchRequest).first else { 70 | XCTFail("Couldn't find converted object") 71 | return 72 | } 73 | 74 | assertEqualAttributes(managedObject, record) 75 | } catch { 76 | XCTFail("\(error)") 77 | } 78 | } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataAttributeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataAttributeTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 03.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | import CloudKit 12 | 13 | @testable import CloudCore 14 | 15 | class CoreDataAttributeTests: CoreDataTestCase { 16 | func testInitWithRelationship() { 17 | let incorrectAttribute = CoreDataAttribute(value: "relationship", attributeName: "singleRelationship", entity: TestEntity.entity()) 18 | XCTAssertNil(incorrectAttribute, "Expected nil because it is relationship, not attribute") 19 | } 20 | 21 | func testMakePlainTextAttributes() { 22 | let correctObject = CorrectObject() 23 | let managedObject = correctObject.insert(in: context) 24 | let record = correctObject.makeRecord() 25 | 26 | for (_, attributeDescription) in managedObject.entity.attributesByName { 27 | // Don't check headers, that class is not inteded to convert headers 28 | if ["recordID", "recordData"].contains(attributeDescription.name) { continue } 29 | 30 | let attributeValue = managedObject.value(forKey: attributeDescription.name) 31 | 32 | // Don't test binary here 33 | if attributeValue is NSData { continue } 34 | 35 | let cdAttribute = CoreDataAttribute(value: attributeValue, attributeName: attributeDescription.name, entity: managedObject.entity) 36 | do { 37 | let cdValue = try cdAttribute?.makeRecordValue() 38 | managedObject.setValue(cdValue, forKey: attributeDescription.name) 39 | } catch { 40 | XCTFail(error) 41 | } 42 | } 43 | 44 | assertEqualPlainTextAttributes(managedObject, record) 45 | } 46 | 47 | func testMakeBinaryAttributes() { 48 | let externalData = "data".data(using: .utf8)! 49 | let externalAttribute = CoreDataAttribute(value: externalData, 50 | attributeName: "externalBinary", 51 | entity: TestEntity.entity()) 52 | 53 | let externalBigData = Data.random(length: 1025*1024) 54 | let externalBigAttribute = CoreDataAttribute(value: externalBigData, 55 | attributeName: "binary", 56 | entity: TestEntity.entity()) 57 | 58 | let internalData = "data".data(using: .utf8)! 59 | let internalAttribute = CoreDataAttribute(value: internalData, 60 | attributeName: "binary", 61 | entity: TestEntity.entity()) 62 | 63 | do { 64 | // External binary 65 | if let recordExternalValue = try externalAttribute?.makeRecordValue() as? CKAsset { 66 | let recordData = try Data(contentsOf: recordExternalValue.fileURL) 67 | XCTAssertEqual(recordData, externalData) 68 | } else { 69 | XCTFail("External binary isn't stored correctly") 70 | } 71 | 72 | // External big binary 73 | if let recordExternalValue = try externalBigAttribute?.makeRecordValue() as? CKAsset { 74 | let recordData = try Data(contentsOf: recordExternalValue.fileURL) 75 | XCTAssertEqual(recordData, externalBigData) 76 | } else { 77 | XCTFail("External big binary isn't stored correctly") 78 | } 79 | 80 | // Internal binary 81 | let recordInternalValue = try internalAttribute?.makeRecordValue() as? Data 82 | XCTAssertEqual(recordInternalValue, internalData) 83 | } catch { 84 | XCTFail(error) 85 | } 86 | } 87 | } 88 | 89 | fileprivate extension Data { 90 | static func random(length: Int) -> Data { 91 | let bytes = [UInt32](repeating: 0, count: length).map { _ in arc4random() } 92 | return Data(bytes: bytes, count: length) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/CoreDataRelationshipTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataRelationshipTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 03.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | import CloudKit 12 | 13 | @testable import CloudCore 14 | 15 | class CoreDataRelationshipTests: CoreDataTestCase { 16 | func testInitWithAttribute() { 17 | let relationship = CoreDataRelationship(value: "attribute", relationshipName: "string", entity: TestEntity.entity()) 18 | XCTAssertNil(relationship, "Expected nil because it is attribute, not relationship") 19 | } 20 | 21 | func testMakeRecordValues() { 22 | // Generate test model 23 | let object = TestEntity(context: context) 24 | try! object.setRecordInformation() 25 | let filledObjectRecord = try! object.restoreRecordWithSystemFields()! 26 | 27 | var manyUsers = [UserEntity]() 28 | var manyUsersRecordsIDs = [CKRecordID]() 29 | for _ in 0...2 { 30 | let user = UserEntity(context: context) 31 | try! user.setRecordInformation() 32 | let userRecord = try! user.restoreRecordWithSystemFields()! 33 | user.recordData = userRecord.encdodedSystemFields 34 | 35 | manyUsers.append(user) 36 | manyUsersRecordsIDs.append(userRecord.recordID) 37 | } 38 | 39 | object.singleRelationship = manyUsers[0] 40 | object.manyRelationship = NSSet(array: manyUsers) 41 | 42 | // Fill testable CKRecord 43 | for name in object.entity.relationshipsByName.keys { 44 | let managedObjectValue = object.value(forKey: name)! 45 | guard let relationship = CoreDataRelationship(value: managedObjectValue, relationshipName: name, entity: object.entity) else { 46 | XCTFail("Failed to initialize CoreDataRelationship with attribute: \(name)") 47 | continue 48 | } 49 | 50 | do { 51 | let recordValue = try relationship.makeRecordValue() 52 | filledObjectRecord.setValue(recordValue, forKey: name) 53 | } catch { 54 | XCTFail("Failed to make record value from attribute: \(name), throwed: \(error)") 55 | } 56 | } 57 | 58 | // Check single relationship 59 | let singleReference = filledObjectRecord.value(forKey: "singleRelationship") as! CKReference 60 | XCTAssertEqual(manyUsersRecordsIDs[0], singleReference.recordID) 61 | 62 | // Check many relationships 63 | let multipleReferences = filledObjectRecord.value(forKey: "manyRelationship") as! [CKReference] 64 | var filledRecordRelationshipIDs = [CKRecordID]() 65 | 66 | for recordReference in multipleReferences { 67 | filledRecordRelationshipIDs.append(recordReference.recordID) 68 | } 69 | 70 | XCTAssertEqual(Set(manyUsersRecordsIDs), Set(filledRecordRelationshipIDs)) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Classes/Upload/ObjectToRecord/ObjectToRecordOperationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectToRecordOperationTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 03.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | import CloudKit 12 | 13 | @testable import CloudCore 14 | 15 | class ObjectToRecordOperationTests: CoreDataTestCase { 16 | 17 | func createTestObject(in context: NSManagedObjectContext) -> (TestEntity, CKRecord) { 18 | let managedObject = CorrectObject().insert(in: context) 19 | let record = try! managedObject.setRecordInformation() 20 | XCTAssertNil(record.value(forKey: "string")) 21 | 22 | return (managedObject, record) 23 | } 24 | 25 | func testGoodOperation() { 26 | let (managedObject, record) = createTestObject(in: context) 27 | let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) 28 | let conversionExpectation = expectation(description: "ConversionCompleted") 29 | 30 | operation.errorCompletionBlock = { XCTFail($0) } 31 | operation.conversionCompletionBlock = { record in 32 | conversionExpectation.fulfill() 33 | assertEqualAttributes(managedObject, record) 34 | } 35 | operation.parentContext = self.context 36 | operation.start() 37 | 38 | waitForExpectations(timeout: 1, handler: nil) 39 | } 40 | 41 | func testContextIsNotDefined() { 42 | let record = createTestObject(in: context).1 43 | let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) 44 | let errorExpectation = expectation(description: "ErrorCalled") 45 | 46 | operation.errorCompletionBlock = { error in 47 | if case CloudCoreError.coreData = error { 48 | errorExpectation.fulfill() 49 | } else { 50 | XCTFail("Unexpected error received") 51 | } 52 | } 53 | operation.conversionCompletionBlock = { _ in 54 | XCTFail("Called success completion block while error has been expected") 55 | } 56 | 57 | operation.start() 58 | waitForExpectations(timeout: 1, handler: nil) 59 | } 60 | 61 | func testNoManagedObjectForOperation() { 62 | let record = CorrectObject().makeRecord() 63 | let _ = TestEntity(context: context) 64 | 65 | let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) 66 | operation.parentContext = self.context 67 | let errorExpectation = expectation(description: "ErrorCalled") 68 | 69 | operation.errorCompletionBlock = { error in 70 | if case CloudCoreError.coreData = error { 71 | errorExpectation.fulfill() 72 | } else { 73 | XCTFail("Unexpected error received") 74 | } 75 | } 76 | operation.conversionCompletionBlock = { _ in 77 | XCTFail("Called success completion block while error has been expected") 78 | } 79 | 80 | operation.start() 81 | waitForExpectations(timeout: 1, handler: nil) 82 | } 83 | 84 | func testOperationPerfomance() { 85 | var records = [CKRecord]() 86 | 87 | for _ in 1...300 { 88 | let record = createTestObject(in: context).1 89 | records.append(record) 90 | } 91 | 92 | try! context.save() 93 | 94 | measure { 95 | let backgroundContext = self.persistentContainer.newBackgroundContext() 96 | let queue = OperationQueue() 97 | 98 | for record in records { 99 | let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) 100 | operation.errorCompletionBlock = { XCTFail($0) } 101 | operation.parentContext = backgroundContext 102 | queue.addOperation(operation) 103 | } 104 | 105 | queue.waitUntilAllOperationsAreFinished() 106 | } 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/CustomFunctions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTAssertThrowsSpecific.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | func XCTAssertThrowsSpecific(_ expression: @autoclosure () throws -> T, _ error: Error) { 12 | XCTAssertThrowsError(expression) { (throwedError) in 13 | XCTAssertEqual("\(throwedError)", "\(error)", "XCTAssertThrowsSpecific: errors are not equal") 14 | } 15 | } 16 | 17 | func XCTFail(_ error: Error) { 18 | XCTFail("\(error)") 19 | } 20 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Extensions/CKRecordIDTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CKRecordID.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 01.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CloudKit 11 | 12 | @testable import CloudCore 13 | 14 | class CKRecordIDTests: XCTestCase { 15 | func testRecordIDEncodeDecode() { 16 | let zoneID = CKRecordZoneID(zoneName: CloudCore.config.zoneID.zoneName, ownerName: CKCurrentUserDefaultName) 17 | let recordID = CKRecordID(recordName: "testName", zoneID: zoneID) 18 | 19 | let encodedString = recordID.encodedString 20 | let restoredRecordID = CKRecordID(encodedString: encodedString) 21 | 22 | XCTAssertEqual(recordID.recordName, restoredRecordID?.recordName) 23 | XCTAssertEqual(recordID.zoneID, restoredRecordID?.zoneID) 24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Extensions/NSEntityDescriptionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSEntityDescription.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 01.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | 12 | @testable import CloudCore 13 | 14 | class NSEntityDescriptionTests: CoreDataTestCase { 15 | func testServiceAttributeNames() { 16 | let correctObject = TestEntity(context: self.context) 17 | 18 | let attributeNames = correctObject.entity.serviceAttributeNames 19 | XCTAssertEqual(attributeNames?.entityName, "TestEntity") 20 | XCTAssertEqual(attributeNames?.recordData, "recordData") 21 | XCTAssertEqual(attributeNames?.recordID, "recordID") 22 | 23 | let incorrectObject = IncorrectEntity(context: self.context) 24 | XCTAssertNil(incorrectObject.entity.serviceAttributeNames) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Extensions/NSManagedObjectTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObjectTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 04.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | import CloudKit 12 | 13 | @testable import CloudCore 14 | 15 | class NSManagedObjectTests: CoreDataTestCase { 16 | func testRestoreRecordWithSystemFields() { 17 | let object = TestEntity(context: context) 18 | do { 19 | try object.setRecordInformation() 20 | 21 | let record = try object.restoreRecordWithSystemFields() 22 | XCTAssertEqual(record?.recordType, "TestEntity") 23 | XCTAssertEqual(record?.recordID.zoneID, CloudCore.config.zoneID) 24 | } catch { 25 | XCTFail("\(error)") 26 | } 27 | } 28 | 29 | /// If no record data is saved 30 | func testRestoreObjectWithoutData() { 31 | let object = TestEntity(context: context) 32 | do { 33 | let record = try object.restoreRecordWithSystemFields() 34 | XCTAssertNil(record) 35 | } catch { 36 | XCTFail("\(error)") 37 | } 38 | } 39 | 40 | // MARK: - Expected throws 41 | 42 | func testSetRecordInformationThrow() { 43 | let object = IncorrectEntity(context: context) 44 | 45 | XCTAssertThrowsSpecific(try object.setRecordInformation(), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) 46 | } 47 | 48 | func testRestoreRecordThrow() { 49 | let object = IncorrectEntity(context: context) 50 | 51 | XCTAssertThrowsSpecific(try object.restoreRecordWithSystemFields(), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/Model/CKRecordTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CKRecord.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 01.03.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CloudKit 11 | 12 | @testable import CloudCore 13 | 14 | class CKRecordTests: XCTestCase { 15 | func testEncodeAndInit() { 16 | let zoneID = CKRecordZoneID(zoneName: "zone", ownerName: CKCurrentUserDefaultName) 17 | let record = CKRecord(recordType: "type", zoneID: zoneID) 18 | record.setValue("testValue", forKey: "testKey") 19 | 20 | let encodedData = record.encdodedSystemFields 21 | guard let restoredRecord = CKRecord(archivedData: encodedData) else { 22 | XCTFail("Failed to restore record from archivedData") 23 | return 24 | } 25 | 26 | XCTAssertEqual(restoredRecord.recordID, record.recordID) 27 | XCTAssertNil(restoredRecord.value(forKey: "testKey")) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/CloudCoreTests/model.xcdatamodeld/model.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // TestableApp 4 | // 5 | // Created by Vasily Ulianov on 29/11/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import CoreData 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | 18 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 19 | // Override point for customization after application launch. 20 | return true 21 | } 22 | 23 | func applicationWillResignActive(_ application: UIApplication) { 24 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 25 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 26 | } 27 | 28 | func applicationDidEnterBackground(_ application: UIApplication) { 29 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 30 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 31 | } 32 | 33 | func applicationWillEnterForeground(_ application: UIApplication) { 34 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 35 | } 36 | 37 | func applicationDidBecomeActive(_ application: UIApplication) { 38 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 39 | } 40 | 41 | func applicationWillTerminate(_ application: UIApplication) { 42 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 43 | // Saves changes in the application's managed object context before the application terminates. 44 | self.saveContext() 45 | } 46 | 47 | // MARK: - Core Data stack 48 | 49 | lazy var persistentContainer: NSPersistentContainer = { 50 | /* 51 | The persistent container for the application. This implementation 52 | creates and returns a container, having loaded the store for the 53 | application to it. This property is optional since there are legitimate 54 | error conditions that could cause the creation of the store to fail. 55 | */ 56 | let container = NSPersistentContainer(name: "TestableApp") 57 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 58 | if let error = error as NSError? { 59 | // Replace this implementation with code to handle the error appropriately. 60 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 61 | 62 | /* 63 | Typical reasons for an error here include: 64 | * The parent directory does not exist, cannot be created, or disallows writing. 65 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 66 | * The device is out of space. 67 | * The store could not be migrated to the current model version. 68 | Check the error message to determine what the actual problem was. 69 | */ 70 | fatalError("Unresolved error \(error), \(error.userInfo)") 71 | } 72 | }) 73 | return container 74 | }() 75 | 76 | // MARK: - Core Data Saving support 77 | 78 | func saveContext () { 79 | let context = persistentContainer.viewContext 80 | if context.hasChanges { 81 | do { 82 | try context.save() 83 | } catch { 84 | // Replace this implementation with code to handle the error appropriately. 85 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 86 | let nserror = error as NSError 87 | fatalError("Unresolved error \(nserror), \(nserror.userInfo)") 88 | } 89 | } 90 | } 91 | 92 | } 93 | 94 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | } 88 | ], 89 | "info" : { 90 | "version" : 1, 91 | "author" : "xcode" 92 | } 93 | } -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/TestableApp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.$(CFBundleIdentifier) 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.developer.ubiquity-kvstore-identifier 16 | $(TeamIdentifierPrefix)$(CFBundleIdentifier) 17 | 18 | 19 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/TestableApp.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | TestableApp.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/TestableApp.xcdatamodeld/TestableApp.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/App/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // TestableApp 4 | // 5 | // Created by Vasily Ulianov on 29/11/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ViewController: UIViewController { 12 | 13 | override func viewDidLoad() { 14 | super.viewDidLoad() 15 | // Do any additional setup after loading the view, typically from a nib. 16 | } 17 | 18 | override func didReceiveMemoryWarning() { 19 | super.didReceiveMemoryWarning() 20 | // Dispose of any resources that can be recreated. 21 | } 22 | 23 | 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/CloudKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitTests.swift 3 | // CloudKitTests 4 | // 5 | // Created by Vasily Ulianov on 29/11/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CloudCore 11 | import CloudKit 12 | import CoreData 13 | 14 | @testable import TestableApp 15 | 16 | class CloudKitTests: CoreDataTestCase { 17 | 18 | override func setUp() { 19 | super.setUp() 20 | configureCloudKitIfNeeded() 21 | CloudKitTests.deleteAllRecordsFromCloudKit() 22 | } 23 | 24 | override class func tearDown() { 25 | super.tearDown() 26 | deleteAllRecordsFromCloudKit() 27 | } 28 | 29 | func testLocalToRemote() { 30 | CloudCore.enable(persistentContainer: persistentContainer) 31 | 32 | let didSyncExpectation = expectation(description: "didSyncToCloudBlock") 33 | let delegateListener = CloudCoreDelegateToBlock() 34 | delegateListener.didSyncToCloudBlock = { didSyncExpectation.fulfill() } 35 | CloudCore.delegate = delegateListener 36 | 37 | // Insert and save managed object 38 | let object = CorrectObject() 39 | let testMO = object.insert(in: context) 40 | object.insertRelationships(in: context, testObject: testMO) 41 | try! context.save() 42 | 43 | wait(for: [didSyncExpectation], timeout: 10) 44 | 45 | // Prepare fresh DB and nullify CloudCore to fetch uploaded data 46 | CloudCore.disable() 47 | CloudCore.tokens = Tokens() 48 | let freshPersistentContainer = loadPersistenContainer() 49 | freshPersistentContainer.viewContext.automaticallyMergesChangesFromParent = true 50 | 51 | // Fetch data from CloudKit 52 | let fetchExpectation = expectation(description: "fetchExpectation") 53 | CloudCore.fetchAndSave(to: freshPersistentContainer, error: { (error) in 54 | XCTFail("Error while trying to fetch from CloudKit: \(error)") 55 | }) { 56 | fetchExpectation.fulfill() 57 | } 58 | 59 | wait(for: [fetchExpectation], timeout: 10) 60 | 61 | // Fetch data from CoreData 62 | let testEntityFetchRequest: NSFetchRequest = TestEntity.fetchRequest() 63 | let testEntity = try! freshPersistentContainer.viewContext.fetch(testEntityFetchRequest).first! 64 | 65 | object.assertEqualAttributes(to: testEntity) 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/CorrectObjectExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CorrectObjectExtension.swift 3 | // CloudKitTests 4 | // 5 | // Created by Vasily Ulianov on 29/11/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension CorrectObject { 12 | 13 | func assertEqualAttributes(to managedObject: TestEntity) { 14 | XCTAssertEqual(managedObject.string, string) 15 | XCTAssertEqual(managedObject.int16, int16) 16 | XCTAssertEqual(managedObject.int32, int32) 17 | XCTAssertEqual(managedObject.int64, int64) 18 | // XCTAssertEqual(managedObject.decimal as Decimal?, decimal as Decimal) // FIXME 19 | XCTAssertEqual(managedObject.double, double) 20 | XCTAssertEqual(managedObject.float, float) 21 | XCTAssertEqual(managedObject.date?.timeIntervalSinceReferenceDate, date.timeIntervalSinceReferenceDate) 22 | XCTAssertEqual(managedObject.bool, bool) 23 | XCTAssertEqual(managedObject.empty, nil) 24 | 25 | // Relationships 26 | XCTAssertEqual(managedObject.singleRelationship?.name, toOneUsername) 27 | 28 | for manyObject in managedObject.manyRelationship!.allObjects { 29 | let userObject = manyObject as! UserEntity 30 | XCTAssertEqual(userObject.name, toManyUsername) 31 | } 32 | 33 | XCTAssertEqual(managedObject.manyRelationship!.count, manyRelationshipsCount) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helpers.swift 3 | // CloudKitTests 4 | // 5 | // Created by Vasily Ulianov on 29/11/2017. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CloudKit 11 | 12 | @testable import CloudCore 13 | 14 | extension CoreDataTestCase { 15 | 16 | func configureCloudKitIfNeeded() { 17 | if UserDefaults.standard.bool(forKey: "isCloudKitConfigured") { return } 18 | 19 | // Setup delegate and expectation 20 | let didSyncExpectation = expectation(description: "didSyncToCloudBlock") 21 | let delegateListener = CloudCoreDelegateToBlock() 22 | delegateListener.didSyncToCloudBlock = { didSyncExpectation.fulfill() } 23 | CloudCore.delegate = delegateListener 24 | 25 | CloudCore.enable(persistentContainer: persistentContainer) 26 | 27 | let object = CorrectObject() 28 | let objectMO = object.insert(in: persistentContainer.viewContext) 29 | 30 | let user = UserEntity(context: persistentContainer.viewContext) 31 | user.name = "test" 32 | user.test = objectMO 33 | 34 | try! context.save() 35 | 36 | wait(for: [didSyncExpectation], timeout: 10) 37 | 38 | let fetchAndSaveExpectation = expectation(description: "fetchAndSave") 39 | CloudCore.fetchAndSave(to: persistentContainer, error: { (error) in 40 | XCTFail("fetchAndSave error: \(error)") 41 | }) { 42 | fetchAndSaveExpectation.fulfill() 43 | } 44 | 45 | wait(for: [fetchAndSaveExpectation], timeout: 10) 46 | UserDefaults.standard.set(true, forKey: "isCloudKitConfigured") 47 | 48 | delegateListener.didSyncToCloudBlock = nil 49 | } 50 | 51 | static func deleteAllRecordsFromCloudKit() { 52 | let operationQueue = OperationQueue() 53 | var recordIdsToDelete = [CKRecordID]() 54 | let publicDatabase = CKContainer.default().privateCloudDatabase 55 | 56 | let queries = [ 57 | CKQuery(recordType: "TestEntity", predicate: NSPredicate(value: true)), 58 | CKQuery(recordType: "UserEntity", predicate: NSPredicate(value: true)) 59 | ] 60 | 61 | for query in queries { 62 | let fetchAllOperations = CKQueryOperation(query: query) 63 | fetchAllOperations.recordFetchedBlock = { recordIdsToDelete.append($0.recordID) } 64 | fetchAllOperations.queryCompletionBlock = { _, error in 65 | if let error = error { 66 | XCTFail("Error while tried to clean test objects: \(error)") 67 | } 68 | } 69 | fetchAllOperations.database = publicDatabase 70 | operationQueue.addOperation(fetchAllOperations) 71 | } 72 | 73 | operationQueue.waitUntilAllOperationsAreFinished() 74 | 75 | if recordIdsToDelete.isEmpty { return } 76 | 77 | let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIdsToDelete) 78 | deleteOperation.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, deletedRecordIDs: [CKRecordID]?, error: Error?) in 79 | if let error = error { 80 | XCTFail("Error while tried to clean test objects: \(error)") 81 | } 82 | } 83 | deleteOperation.database = publicDatabase 84 | operationQueue.addOperation(deleteOperation) 85 | operationQueue.waitUntilAllOperationsAreFinished() 86 | } 87 | 88 | } 89 | 90 | class CloudCoreDelegateToBlock: CloudCoreDelegate { 91 | 92 | var didSyncToCloudBlock: (() -> Void)? 93 | 94 | func didSyncToCloud() { 95 | didSyncToCloudBlock?() 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/CloudKitTests/Resources/model.xcdatamodeld/model.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Tests/Shared/CoreDataTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataTests.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | 12 | class CoreDataTestCase: XCTestCase { 13 | var context: NSManagedObjectContext { return persistentContainer.viewContext } 14 | 15 | private(set) var persistentContainer: NSPersistentContainer! 16 | 17 | func loadPersistenContainer() -> NSPersistentContainer { 18 | let bundle = Bundle(for: CoreDataTestCase.self) 19 | let url = bundle.url(forResource: "model", withExtension: "momd") 20 | let model = NSManagedObjectModel(contentsOf: url!)! 21 | 22 | let container = NSPersistentContainer(name: "model", managedObjectModel: model) 23 | let description = NSPersistentStoreDescription() 24 | description.type = NSInMemoryStoreType 25 | container.persistentStoreDescriptions = [description] 26 | 27 | let expect = expectation(description: "CoreDataStackInitialize") 28 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 29 | expect.fulfill() 30 | if let error = error { 31 | fatalError("Unable to load NSPersistentContainer: \(error)") 32 | } 33 | }) 34 | wait(for: [expect], timeout: 1) 35 | 36 | return container 37 | } 38 | 39 | override func setUp() { 40 | super.setUp() 41 | persistentContainer = loadPersistenContainer() 42 | context.automaticallyMergesChangesFromParent = true 43 | } 44 | 45 | override func tearDown() { 46 | super.tearDown() 47 | persistentContainer = nil 48 | } 49 | 50 | override class func tearDown() { 51 | super.tearDown() 52 | clearTemporaryFolder() 53 | } 54 | 55 | private static func clearTemporaryFolder() { 56 | let fileManager = FileManager.default 57 | let tempFolder = fileManager.temporaryDirectory 58 | 59 | do { 60 | let filePaths = try fileManager.contentsOfDirectory(at: tempFolder, includingPropertiesForKeys: nil, options: []) 61 | for filePath in filePaths { 62 | try fileManager.removeItem(at: filePath) 63 | } 64 | } catch let error as NSError { 65 | XCTFail("Could not clear temp folder: \(error.debugDescription)") 66 | } 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /Tests/Shared/CorrectObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CorrectManagedObjectRecord.swift 3 | // CloudCore 4 | // 5 | // Created by Vasily Ulianov on 02.02.17. 6 | // Copyright © 2017 Vasily Ulianov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CoreData 11 | import CloudKit 12 | 13 | @testable import CloudCore 14 | 15 | struct CorrectObject { 16 | let recordData: Data = CKRecord(recordType: "TestEntity").encdodedSystemFields 17 | let binary = "binary data".data(using: .utf8)! 18 | let externalBinary = "external binary data".data(using: .utf8)! 19 | 20 | let string = "text" 21 | 22 | let int16 = Int16.max 23 | let int32 = Int32.max 24 | let int64 = Int64.max 25 | let decimal = NSDecimalNumber.maximum 26 | let double = Double.greatestFiniteMagnitude 27 | let float = Float.greatestFiniteMagnitude 28 | 29 | let date = Date() 30 | let bool = true 31 | 32 | let toOneUsername = "toOneUsername" 33 | let toManyUsername = "toManyUsername" 34 | let manyRelationshipsCount = 2 35 | 36 | @discardableResult 37 | func insert(in context: NSManagedObjectContext) -> TestEntity { 38 | let managedObject = TestEntity(context: context) 39 | 40 | // Header 41 | managedObject.recordData = self.recordData as Data 42 | 43 | // Binary 44 | managedObject.binary = binary as Data 45 | managedObject.externalBinary = externalBinary as Data 46 | managedObject.transformable = NSData() 47 | 48 | // Plain-text 49 | managedObject.string = self.string 50 | 51 | managedObject.int16 = self.int16 52 | managedObject.int32 = self.int32 53 | managedObject.int64 = self.int64 54 | managedObject.decimal = self.decimal 55 | managedObject.double = self.double 56 | managedObject.float = self.float 57 | 58 | managedObject.date = self.date 59 | managedObject.bool = self.bool 60 | 61 | return managedObject 62 | } 63 | 64 | func insertRelationships(in context: NSManagedObjectContext, testObject: TestEntity) { 65 | let userSingle = UserEntity(context: context) 66 | userSingle.name = toOneUsername 67 | 68 | let userMany1 = UserEntity(context: context) 69 | userMany1.name = toManyUsername 70 | 71 | let userMany2 = UserEntity(context: context) 72 | userMany2.name = toManyUsername 73 | 74 | testObject.singleRelationship = userSingle 75 | testObject.manyRelationship = NSSet(array: [userMany1, userMany2]) 76 | } 77 | 78 | func makeRecord() -> CKRecord { 79 | let record = CKRecord(recordType: "TestEntity", zoneID: CloudCore.config.zoneID) 80 | 81 | let asset = try? CoreDataAttribute.createAsset(for: externalBinary) 82 | XCTAssertNotNil(asset) 83 | record.setValue(asset, forKey: "externalBinary") 84 | 85 | record.setValue(self.binary, forKey: "binary") 86 | 87 | record.setValue(self.string, forKey: "string") 88 | record.setValue(self.int16, forKey: "int16") 89 | record.setValue(self.int32, forKey: "int32") 90 | record.setValue(self.int64, forKey: "int64") 91 | record.setValue(self.decimal, forKey: "decimal") 92 | record.setValue(self.double, forKey: "double") 93 | record.setValue(self.float, forKey: "float") 94 | record.setValue(self.date, forKey: "date") 95 | record.setValue(self.bool, forKey: "bool") 96 | 97 | return record 98 | } 99 | } 100 | 101 | func assertEqualAttributes(_ managedObject: TestEntity, _ record: CKRecord) { 102 | // Headers 103 | if let encodedRecordData = managedObject.recordData as Data? { 104 | let recordFromObject = CKRecord(archivedData: encodedRecordData) 105 | 106 | XCTAssertEqual(recordFromObject?.recordID, record.recordID) 107 | } 108 | 109 | assertEqualPlainTextAttributes(managedObject, record) 110 | assertEqualBinaryAttributes(managedObject, record) 111 | } 112 | 113 | func assertEqualPlainTextAttributes(_ managedObject: TestEntity, _ record: CKRecord) { 114 | XCTAssertEqual(managedObject.string, record.value(forKey: "string") as! String?) 115 | 116 | let recordInt16 = (record.value(forKey: "int16") as! NSNumber?)?.int16Value ?? 0 117 | XCTAssertEqual(managedObject.int16, recordInt16) 118 | 119 | let recordInt32 = (record.value(forKey: "int32") as! NSNumber?)?.int32Value ?? 0 120 | XCTAssertEqual(managedObject.int32, recordInt32) 121 | 122 | let recordInt64 = (record.value(forKey: "int64") as! NSNumber?)?.int64Value ?? 0 123 | XCTAssertEqual(managedObject.int64, recordInt64) 124 | 125 | let recordDecimal = (record.value(forKey: "decimal") as! NSNumber?)?.decimalValue ?? 0 126 | XCTAssertEqual(managedObject.decimal as Decimal?, recordDecimal) 127 | 128 | let recordDouble = (record.value(forKey: "double") as! NSNumber?)?.doubleValue ?? 0 129 | XCTAssertEqual(managedObject.double, recordDouble) 130 | 131 | let recordFloat = (record.value(forKey: "float") as! NSNumber?)?.floatValue ?? 0 132 | XCTAssertEqual(managedObject.float, recordFloat) 133 | 134 | let recordDate = (record.value(forKey: "date") as! NSDate?)?.timeIntervalSinceReferenceDate 135 | XCTAssertEqual(managedObject.date?.timeIntervalSinceReferenceDate, recordDate) 136 | 137 | let recordBool = record.value(forKey: "bool") as! Bool? ?? false 138 | XCTAssertEqual(managedObject.bool, recordBool) 139 | 140 | XCTAssertEqual(nil, record.value(forKey: "empty") as! String?) 141 | XCTAssertEqual(managedObject.empty, nil) 142 | } 143 | 144 | func assertEqualBinaryAttributes(_ managedObject: TestEntity, _ record: CKRecord) { 145 | if let recordAsset = record.value(forKey: "externalBinary") as! CKAsset? { 146 | let downloadedData = try! Data(contentsOf: recordAsset.fileURL) 147 | XCTAssertEqual(managedObject.externalBinary, downloadedData) 148 | } 149 | 150 | XCTAssertEqual(managedObject.binary, record.value(forKey: "binary") as? Data) 151 | } 152 | --------------------------------------------------------------------------------