├── .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 | [](https://sorix.github.io/CloudCore/)
10 | [](https://cocoapods.org/pods/CloudCore)
11 | 
12 | 
13 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------